openflume 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openflume/__init__.py +79 -0
- openflume/__main__.py +90 -0
- openflume/_bridge.py +279 -0
- openflume/_js/coolprop.wasm +0 -0
- openflume/_js/openflume-bridge.mjs +35226 -0
- openflume/errors.py +103 -0
- openflume/fluids.py +234 -0
- openflume/network.py +222 -0
- openflume/py.typed +0 -0
- openflume/results.py +226 -0
- openflume-0.3.1.dist-info/METADATA +120 -0
- openflume-0.3.1.dist-info/RECORD +15 -0
- openflume-0.3.1.dist-info/WHEEL +4 -0
- openflume-0.3.1.dist-info/entry_points.txt +2 -0
- openflume-0.3.1.dist-info/licenses/LICENSE +21 -0
openflume/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""OpenFLUME — thermal-fluid network solver, from Python.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import openflume as of
|
|
6
|
+
|
|
7
|
+
net = of.load("feed_system.fn") # or of.example("Tank blowdown")
|
|
8
|
+
errors = net.validate() # [] means runnable
|
|
9
|
+
result = net.solve() # steady or transient per settings.mode
|
|
10
|
+
print(result.converged, result.node("tank")["pressure"])
|
|
11
|
+
|
|
12
|
+
n2 = of.Fluid.real("Nitrogen")
|
|
13
|
+
n2.density(101325, 300.0) # kg/m³
|
|
14
|
+
|
|
15
|
+
Everything is SI (Pa, K, kg/s, m, J/kg). The solver core is the same
|
|
16
|
+
TypeScript code that runs the web app, executed in a bundled Node.js runtime
|
|
17
|
+
(see :class:`Session`). Models that embed JavaScript user components run that
|
|
18
|
+
code with the privileges of your Python process — only solve models you trust.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError
|
|
24
|
+
from importlib.metadata import version as _dist_version
|
|
25
|
+
|
|
26
|
+
from ._bridge import Session, default_session, find_node, shutdown
|
|
27
|
+
from .errors import (
|
|
28
|
+
BridgeError,
|
|
29
|
+
ConfigDecodeError,
|
|
30
|
+
CoreError,
|
|
31
|
+
InvalidRequestError,
|
|
32
|
+
OpenFLUMEError,
|
|
33
|
+
ParseError,
|
|
34
|
+
UnknownExampleError,
|
|
35
|
+
UnsupportedPropertyError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
)
|
|
38
|
+
from .fluids import Fluid, combustion_gas, combustion_propellants, fluids
|
|
39
|
+
from .network import Network, example, examples, load, solve, validate
|
|
40
|
+
from .results import SteadyResult, TransientResult
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
__version__ = _dist_version("openflume")
|
|
44
|
+
except PackageNotFoundError: # running from a source tree without install
|
|
45
|
+
__version__ = "0+unknown"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
# models
|
|
50
|
+
"Network",
|
|
51
|
+
"load",
|
|
52
|
+
"example",
|
|
53
|
+
"examples",
|
|
54
|
+
"solve",
|
|
55
|
+
"validate",
|
|
56
|
+
# results
|
|
57
|
+
"SteadyResult",
|
|
58
|
+
"TransientResult",
|
|
59
|
+
# fluids
|
|
60
|
+
"Fluid",
|
|
61
|
+
"fluids",
|
|
62
|
+
"combustion_gas",
|
|
63
|
+
"combustion_propellants",
|
|
64
|
+
# runtime
|
|
65
|
+
"Session",
|
|
66
|
+
"default_session",
|
|
67
|
+
"shutdown",
|
|
68
|
+
"find_node",
|
|
69
|
+
# errors
|
|
70
|
+
"OpenFLUMEError",
|
|
71
|
+
"BridgeError",
|
|
72
|
+
"ConfigDecodeError",
|
|
73
|
+
"ValidationError",
|
|
74
|
+
"ParseError",
|
|
75
|
+
"CoreError",
|
|
76
|
+
"UnknownExampleError",
|
|
77
|
+
"InvalidRequestError",
|
|
78
|
+
"UnsupportedPropertyError",
|
|
79
|
+
]
|
openflume/__main__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Command line: ``openflume info | validate MODEL | solve MODEL [-o OUT]``.
|
|
2
|
+
|
|
3
|
+
Also runnable as ``python -m openflume``. Results are written as JSON (the
|
|
4
|
+
core's raw result object); exit status is 0 on success, 1 on validation
|
|
5
|
+
failure or a non-converged solve, 2 on usage/IO errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from ._bridge import _encode_nonfinite, default_session, find_node
|
|
17
|
+
from .errors import OpenFLUMEError
|
|
18
|
+
from .network import Network
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cmd_info(_: argparse.Namespace) -> int:
|
|
22
|
+
info = default_session().start().info
|
|
23
|
+
print(f"openflume {__version__}")
|
|
24
|
+
print(f"core {info.get('version')} (config schema v{info.get('configVersion')})")
|
|
25
|
+
print(f"node {info.get('node')} ({find_node()})")
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
30
|
+
errors = Network.load(args.model).validate()
|
|
31
|
+
for e in errors:
|
|
32
|
+
print(e)
|
|
33
|
+
if not errors:
|
|
34
|
+
print("valid")
|
|
35
|
+
return 1 if errors else 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cmd_solve(args: argparse.Namespace) -> int:
|
|
39
|
+
net = Network.load(args.model)
|
|
40
|
+
|
|
41
|
+
def progress(p: dict) -> None:
|
|
42
|
+
if p.get("kind") == "transient":
|
|
43
|
+
print(f"\r t = {p['time']:.6g} / {p['endTime']:.6g} s", end="", file=sys.stderr)
|
|
44
|
+
else:
|
|
45
|
+
print(f"\r iter {p['iteration']} residual {p['residual']:.3e}", end="", file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
result = net.solve(args.mode, progress=None if args.quiet else progress)
|
|
48
|
+
if not args.quiet:
|
|
49
|
+
print(file=sys.stderr)
|
|
50
|
+
print(repr(result), file=sys.stderr)
|
|
51
|
+
text = json.dumps(_encode_nonfinite(result.raw), indent=None if args.compact else 2)
|
|
52
|
+
if args.output:
|
|
53
|
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
|
54
|
+
else:
|
|
55
|
+
print(text)
|
|
56
|
+
return 0 if result.converged else 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
parser = argparse.ArgumentParser(prog="openflume", description="OpenFLUME thermal-fluid network solver")
|
|
61
|
+
parser.add_argument("--version", action="version", version=f"openflume {__version__}")
|
|
62
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
63
|
+
|
|
64
|
+
sub.add_parser("info", help="show package, core and Node.js versions").set_defaults(fn=_cmd_info)
|
|
65
|
+
|
|
66
|
+
p_val = sub.add_parser("validate", help="validate a .fn/.json model")
|
|
67
|
+
p_val.add_argument("model")
|
|
68
|
+
p_val.set_defaults(fn=_cmd_validate)
|
|
69
|
+
|
|
70
|
+
p_solve = sub.add_parser("solve", help="solve a .fn/.json model and print the JSON result")
|
|
71
|
+
p_solve.add_argument("model")
|
|
72
|
+
p_solve.add_argument("-o", "--output", help="write the JSON result here instead of stdout")
|
|
73
|
+
p_solve.add_argument("--mode", choices=["steady", "transient"], help="override settings.mode")
|
|
74
|
+
p_solve.add_argument("-q", "--quiet", action="store_true", help="no progress on stderr")
|
|
75
|
+
p_solve.add_argument("--compact", action="store_true", help="single-line JSON")
|
|
76
|
+
p_solve.set_defaults(fn=_cmd_solve)
|
|
77
|
+
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
try:
|
|
80
|
+
return int(args.fn(args))
|
|
81
|
+
except OpenFLUMEError as exc:
|
|
82
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(main())
|
openflume/_bridge.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Node.js subprocess bridge to the bundled OpenFLUME core.
|
|
2
|
+
|
|
3
|
+
One long-lived ``node`` process per :class:`Session`, speaking newline-delimited
|
|
4
|
+
JSON over stdio (protocol documented in python/bridge/server.ts). Requests are
|
|
5
|
+
serialized with a lock; the solvers are synchronous so there is no benefit to
|
|
6
|
+
pipelining. Use several sessions (e.g. one per worker process) for parallelism.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
from collections import deque
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .errors import BridgeError, from_payload
|
|
25
|
+
|
|
26
|
+
PROTOCOL_VERSION = 1
|
|
27
|
+
MIN_NODE_MAJOR = 22
|
|
28
|
+
|
|
29
|
+
_JS_DIR = Path(__file__).resolve().parent / "_js"
|
|
30
|
+
BRIDGE_SCRIPT = _JS_DIR / "openflume-bridge.mjs"
|
|
31
|
+
|
|
32
|
+
_log = logging.getLogger("openflume.node")
|
|
33
|
+
|
|
34
|
+
ProgressCallback = Callable[[dict[str, Any]], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── non-finite number codec (mirrors server.ts) ─────────────────────────────
|
|
38
|
+
_NUM_TAG = "$num"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _encode_nonfinite(value: Any) -> Any:
|
|
42
|
+
if isinstance(value, float):
|
|
43
|
+
if math.isnan(value):
|
|
44
|
+
return {_NUM_TAG: "NaN"}
|
|
45
|
+
if math.isinf(value):
|
|
46
|
+
return {_NUM_TAG: "Infinity" if value > 0 else "-Infinity"}
|
|
47
|
+
return value
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
return {k: _encode_nonfinite(v) for k, v in value.items()}
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
return [_encode_nonfinite(v) for v in value]
|
|
52
|
+
# numpy scalars / arrays, without importing numpy
|
|
53
|
+
tolist = getattr(value, "tolist", None)
|
|
54
|
+
if callable(tolist) and type(value).__module__.startswith("numpy"):
|
|
55
|
+
return _encode_nonfinite(tolist())
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _decode_hook(obj: dict[str, Any]) -> Any:
|
|
60
|
+
if len(obj) == 1 and _NUM_TAG in obj:
|
|
61
|
+
return float(obj[_NUM_TAG])
|
|
62
|
+
return obj
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def dumps(value: Any) -> str:
|
|
66
|
+
return json.dumps(_encode_nonfinite(value), allow_nan=False, separators=(",", ":"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def loads(text: str) -> Any:
|
|
70
|
+
return json.loads(text, object_hook=_decode_hook)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Node discovery ──────────────────────────────────────────────────────────
|
|
74
|
+
def _wheel_node() -> str | None:
|
|
75
|
+
try:
|
|
76
|
+
import nodejs_wheel # type: ignore[import-not-found]
|
|
77
|
+
except ImportError:
|
|
78
|
+
return None
|
|
79
|
+
root = Path(nodejs_wheel.__file__).resolve().parent
|
|
80
|
+
candidate = root / "node.exe" if os.name == "nt" else root / "bin" / "node"
|
|
81
|
+
return str(candidate) if candidate.is_file() else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_node() -> str:
|
|
85
|
+
"""Return the Node.js executable to use.
|
|
86
|
+
|
|
87
|
+
Order: ``$OPENFLUME_NODE``, the ``nodejs-wheel-binaries`` runtime installed
|
|
88
|
+
alongside openflume, then ``node`` on ``PATH``.
|
|
89
|
+
"""
|
|
90
|
+
override = os.environ.get("OPENFLUME_NODE")
|
|
91
|
+
if override:
|
|
92
|
+
return override
|
|
93
|
+
found = _wheel_node() or shutil.which("node")
|
|
94
|
+
if not found:
|
|
95
|
+
raise BridgeError(
|
|
96
|
+
"openflume needs Node.js >= 22. Install the bundled runtime with "
|
|
97
|
+
"`pip install nodejs-wheel-binaries`, install Node.js on PATH, or set "
|
|
98
|
+
"OPENFLUME_NODE to a node executable."
|
|
99
|
+
)
|
|
100
|
+
return found
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── session ─────────────────────────────────────────────────────────────────
|
|
104
|
+
class Session:
|
|
105
|
+
"""A running instance of the OpenFLUME core.
|
|
106
|
+
|
|
107
|
+
Most code never needs this: module-level functions use a shared default
|
|
108
|
+
session that starts on first use and stops at interpreter exit. Create one
|
|
109
|
+
explicitly to pick a Node executable, or to isolate state (e.g. one per
|
|
110
|
+
worker process). Usable as a context manager.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, node: str | os.PathLike[str] | None = None) -> None:
|
|
114
|
+
self._node = os.fspath(node) if node is not None else None
|
|
115
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
116
|
+
self._lock = threading.RLock()
|
|
117
|
+
self._next_id = 0
|
|
118
|
+
self._stderr_tail: deque[str] = deque(maxlen=50)
|
|
119
|
+
self.info: dict[str, Any] = {}
|
|
120
|
+
|
|
121
|
+
# lifecycle -----------------------------------------------------------------
|
|
122
|
+
def start(self) -> Session:
|
|
123
|
+
with self._lock:
|
|
124
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
125
|
+
return self
|
|
126
|
+
if not BRIDGE_SCRIPT.is_file():
|
|
127
|
+
raise BridgeError(
|
|
128
|
+
f"bundled OpenFLUME core missing at {BRIDGE_SCRIPT}; this "
|
|
129
|
+
"installation is incomplete (source checkouts need "
|
|
130
|
+
"`npm run build:python`)."
|
|
131
|
+
)
|
|
132
|
+
node = self._node or find_node()
|
|
133
|
+
self._stderr_tail.clear()
|
|
134
|
+
try:
|
|
135
|
+
self._proc = subprocess.Popen(
|
|
136
|
+
[node, "--no-warnings", str(BRIDGE_SCRIPT)],
|
|
137
|
+
stdin=subprocess.PIPE,
|
|
138
|
+
stdout=subprocess.PIPE,
|
|
139
|
+
stderr=subprocess.PIPE,
|
|
140
|
+
text=True,
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
bufsize=1,
|
|
143
|
+
cwd=str(_JS_DIR),
|
|
144
|
+
# Own process group: a terminal Ctrl-C reaches only Python,
|
|
145
|
+
# which then kills Node (see _request), instead of Node
|
|
146
|
+
# dying first and surfacing as a BridgeError.
|
|
147
|
+
start_new_session=os.name != "nt",
|
|
148
|
+
creationflags=(
|
|
149
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
raise BridgeError(f"could not start Node.js ({node}): {exc}") from exc
|
|
154
|
+
threading.Thread(
|
|
155
|
+
target=self._pump_stderr, args=(self._proc,), name="openflume-node-stderr", daemon=True
|
|
156
|
+
).start()
|
|
157
|
+
info = self._request("hello", {}, None)
|
|
158
|
+
if info.get("protocol") != PROTOCOL_VERSION:
|
|
159
|
+
self.close()
|
|
160
|
+
raise BridgeError(f"bridge protocol mismatch: expected {PROTOCOL_VERSION}, got {info.get('protocol')}")
|
|
161
|
+
major = int(str(info.get("node", "0")).split(".")[0] or 0)
|
|
162
|
+
if major < MIN_NODE_MAJOR:
|
|
163
|
+
self.close()
|
|
164
|
+
raise BridgeError(
|
|
165
|
+
f"Node.js {info.get('node')} at {node} is too old; openflume needs >= {MIN_NODE_MAJOR}."
|
|
166
|
+
)
|
|
167
|
+
self.info = info
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
"""Stop the Node process. The session restarts automatically on next use."""
|
|
172
|
+
with self._lock:
|
|
173
|
+
proc, self._proc = self._proc, None
|
|
174
|
+
if proc is None:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
if proc.stdin:
|
|
178
|
+
proc.stdin.close()
|
|
179
|
+
proc.wait(timeout=5)
|
|
180
|
+
except Exception:
|
|
181
|
+
proc.kill()
|
|
182
|
+
proc.wait()
|
|
183
|
+
|
|
184
|
+
def _kill(self) -> None:
|
|
185
|
+
proc, self._proc = self._proc, None
|
|
186
|
+
if proc is not None:
|
|
187
|
+
proc.kill()
|
|
188
|
+
proc.wait()
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def running(self) -> bool:
|
|
192
|
+
return self._proc is not None and self._proc.poll() is None
|
|
193
|
+
|
|
194
|
+
def __enter__(self) -> Session:
|
|
195
|
+
return self.start()
|
|
196
|
+
|
|
197
|
+
def __exit__(self, *exc: object) -> None:
|
|
198
|
+
self.close()
|
|
199
|
+
|
|
200
|
+
# I/O -----------------------------------------------------------------------
|
|
201
|
+
def _pump_stderr(self, proc: subprocess.Popen[str]) -> None:
|
|
202
|
+
assert proc.stderr is not None
|
|
203
|
+
for line in proc.stderr:
|
|
204
|
+
line = line.rstrip("\n")
|
|
205
|
+
self._stderr_tail.append(line)
|
|
206
|
+
_log.info("%s", line)
|
|
207
|
+
|
|
208
|
+
def _died(self) -> BridgeError:
|
|
209
|
+
code = self._proc.poll() if self._proc else None
|
|
210
|
+
tail = "\n".join(self._stderr_tail)
|
|
211
|
+
self._proc = None
|
|
212
|
+
msg = f"OpenFLUME Node process exited unexpectedly (exit code {code})"
|
|
213
|
+
return BridgeError(msg + (f":\n{tail}" if tail else ""))
|
|
214
|
+
|
|
215
|
+
def call(
|
|
216
|
+
self, method: str, params: dict[str, Any] | None = None, *, on_progress: ProgressCallback | None = None
|
|
217
|
+
) -> Any:
|
|
218
|
+
"""Invoke a bridge method and return its decoded result."""
|
|
219
|
+
with self._lock:
|
|
220
|
+
self.start()
|
|
221
|
+
return self._request(method, params or {}, on_progress)
|
|
222
|
+
|
|
223
|
+
def _request(self, method: str, params: dict[str, Any], on_progress: ProgressCallback | None) -> Any:
|
|
224
|
+
proc = self._proc
|
|
225
|
+
assert proc is not None and proc.stdin is not None and proc.stdout is not None
|
|
226
|
+
self._next_id += 1
|
|
227
|
+
req_id = self._next_id
|
|
228
|
+
try:
|
|
229
|
+
proc.stdin.write(dumps({"id": req_id, "method": method, "params": params}) + "\n")
|
|
230
|
+
proc.stdin.flush()
|
|
231
|
+
except (BrokenPipeError, OSError):
|
|
232
|
+
raise self._died() from None
|
|
233
|
+
try:
|
|
234
|
+
while True:
|
|
235
|
+
line = proc.stdout.readline()
|
|
236
|
+
if not line:
|
|
237
|
+
raise self._died()
|
|
238
|
+
msg = loads(line)
|
|
239
|
+
if msg.get("id") != req_id:
|
|
240
|
+
continue # stale reply from an interrupted request
|
|
241
|
+
if "event" in msg:
|
|
242
|
+
if on_progress is not None and msg["event"] == "progress":
|
|
243
|
+
on_progress(msg.get("data", {}))
|
|
244
|
+
continue
|
|
245
|
+
if "error" in msg:
|
|
246
|
+
raise from_payload(msg["error"])
|
|
247
|
+
return msg.get("result")
|
|
248
|
+
except KeyboardInterrupt:
|
|
249
|
+
# The solve is synchronous inside Node and cannot be cancelled
|
|
250
|
+
# cooperatively; kill it so the session is usable again.
|
|
251
|
+
self._kill()
|
|
252
|
+
raise
|
|
253
|
+
except BaseException:
|
|
254
|
+
if self._proc is not None and self._proc.poll() is not None:
|
|
255
|
+
self._proc = None
|
|
256
|
+
raise
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_default: Session | None = None
|
|
260
|
+
_default_lock = threading.Lock()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def default_session() -> Session:
|
|
264
|
+
"""The shared session used by module-level functions."""
|
|
265
|
+
global _default
|
|
266
|
+
with _default_lock:
|
|
267
|
+
if _default is None:
|
|
268
|
+
_default = Session()
|
|
269
|
+
return _default
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def shutdown() -> None:
|
|
273
|
+
"""Stop the shared default session (it restarts on next use)."""
|
|
274
|
+
with _default_lock:
|
|
275
|
+
if _default is not None:
|
|
276
|
+
_default.close()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
atexit.register(shutdown)
|
|
Binary file
|