opensysml 0.3.0__tar.gz → 0.3.2__tar.gz
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.
- {opensysml-0.3.0 → opensysml-0.3.2}/PKG-INFO +73 -22
- {opensysml-0.3.0 → opensysml-0.3.2}/README.md +70 -19
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/__init__.py +32 -20
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/_version.py +1 -1
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/binary.py +132 -18
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/capabilities.py +7 -11
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/connection.py +388 -638
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/errors.py +36 -6
- opensysml-0.3.2/opensysml/signing.py +308 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/typed.py +14 -5
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/PKG-INFO +73 -22
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/SOURCES.txt +2 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/requires.txt +2 -2
- {opensysml-0.3.0 → opensysml-0.3.2}/pyproject.toml +6 -2
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_api.py +4 -4
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_binary.py +7 -2
- opensysml-0.3.2/tests/test_connection.py +386 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_conversion.py +18 -1
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_errors.py +13 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_evaluate_subject.py +1 -1
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_generate_golden.py +6 -5
- opensysml-0.3.2/tests/test_lifecycle.py +401 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_model_surface_integration.py +1 -1
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_quantity.py +5 -6
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_runtime_integration.py +14 -14
- opensysml-0.3.2/tests/test_signing.py +523 -0
- opensysml-0.3.2/tests/test_stale_service.py +380 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_target.py +2 -1
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_typed.py +26 -0
- opensysml-0.3.0/tests/test_connection.py +0 -585
- opensysml-0.3.0/tests/test_lifecycle.py +0 -364
- opensysml-0.3.0/tests/test_stale_service.py +0 -727
- {opensysml-0.3.0 → opensysml-0.3.2}/LICENSE +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/_dist.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/conversion.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/diagnostic.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/edit.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/enumeration.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/generate.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/instance.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/model.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/__init__.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/sysml_pb2.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/sysml_pb2.pyi +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/sysml_pb2_grpc.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/py.typed +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/query.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/symbol.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/typefacts.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/values.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/verdict.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/dependency_links.txt +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/entry_points.txt +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/top_level.txt +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/setup.cfg +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_capabilities.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_diagnostic.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_edit.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_enumeration.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_feature_values.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_feature_values_capability.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_generate.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_instance.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_integration.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_legacy_pysysml_placeholder.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_model.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_pin_release_checksums.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_query.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_runtime.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_strict_and_lookup.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_strict_conformance.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_symbol.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_unset.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_verification.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_version.py +0 -0
- {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_wire_compat.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opensysml
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Python client library for OpenSysML, a SysML v2 parser and execution runtime
|
|
5
5
|
Author: Open-MBEE
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,11 +28,11 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: grpcio>=1.83.0
|
|
30
30
|
Requires-Dist: protobuf>=7.35.1
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
Requires-Dist: psutil>=5.9.0
|
|
31
|
+
Requires-Dist: sigstore<5,>=4.5.0
|
|
33
32
|
Provides-Extra: dev
|
|
34
33
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
35
34
|
Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
35
|
+
Requires-Dist: psutil>=5.9.0; extra == "dev"
|
|
36
36
|
Requires-Dist: grpcio-tools>=1.83.0; extra == "dev"
|
|
37
37
|
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
38
38
|
Requires-Dist: types-protobuf>=4.24.0; extra == "dev"
|
|
@@ -48,7 +48,7 @@ Python client for OpenSysML: parse, inspect and execute SysML v2 models over the
|
|
|
48
48
|
`sysml-grpc` service.
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
pip install opensysml # from PyPI
|
|
51
|
+
pip install opensysml # from PyPI
|
|
52
52
|
pip install -e python/ # or from a checkout, at the repository root
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -60,8 +60,7 @@ print(model.eval("1 + 2 * 3")) # 7
|
|
|
60
60
|
|
|
61
61
|
print(model.eval("mass", subject="Demo::sedan")) # 1200.0 — that object, not the default
|
|
62
62
|
# requires the service's evaluate_subject
|
|
63
|
-
# capability
|
|
64
|
-
# service that would ignore the subject
|
|
63
|
+
# capability; the client checks it first
|
|
65
64
|
|
|
66
65
|
vehicle = model["Vehicle"] # by short name or FQN
|
|
67
66
|
vehicle.attributes() # own and inherited, with resolved facts
|
|
@@ -87,22 +86,74 @@ Every call goes through the `sysml-grpc` service, which `opensysml` starts for y
|
|
|
87
86
|
|
|
88
87
|
## Service ownership
|
|
89
88
|
|
|
90
|
-
`opensysml` never stops a service it did not
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
the
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
-
|
|
105
|
-
|
|
89
|
+
`opensysml` uses a service of its own, and never stops a service it did not
|
|
90
|
+
start.
|
|
91
|
+
|
|
92
|
+
- A connection made without naming a service starts a **private child** of this
|
|
93
|
+
interpreter. The child binds port 0, so the kernel assigns the port, and it
|
|
94
|
+
reports the address it was given on its stdout — no port is chosen, probed or
|
|
95
|
+
retried by the client, and two interpreters starting at once cannot collide.
|
|
96
|
+
- One private child serves **every** connection of an interpreter that needs the
|
|
97
|
+
same service release. The first of them starts it; it stops when the last one
|
|
98
|
+
closes, or when the interpreter exits. Sharing it shares its parse cache,
|
|
99
|
+
which is what makes a second connection cheap (see below).
|
|
100
|
+
- Its lifetime is this interpreter's. Nothing is recorded on disk about it, no
|
|
101
|
+
other process adopts it, and a service another process left listening is
|
|
102
|
+
neither reused nor cleaned up.
|
|
103
|
+
- Connecting to a service `opensysml` did not start is explicit: pass a host and
|
|
104
|
+
port (`opensysml.connect("localhost", 50051)`, or `connect("localhost:50051")`),
|
|
105
|
+
set `$OPENSYSML_SERVICE=host:port`, or pass `auto_start=False` to require a
|
|
106
|
+
service the caller manages. Closing such a connection leaves it running.
|
|
107
|
+
|
|
108
|
+
### No orphans
|
|
109
|
+
|
|
110
|
+
The client holds the write end of the child's **stdin pipe** and never writes to
|
|
111
|
+
it; the child reads its stdin and shuts down on end of file. Nothing else holds
|
|
112
|
+
that write end, so the pipe closes when the owning process goes away — and it is
|
|
113
|
+
the kernel that closes it, not any code of ours. That survives what an `atexit`
|
|
114
|
+
hook or a supervisor thread does not: `SIGKILL`, `os._exit`, a fatal interpreter
|
|
115
|
+
error, and a crash during shutdown. On an orderly close the client also closes
|
|
116
|
+
stdin itself and then signals the child, so exit is prompt rather than eventual.
|
|
117
|
+
|
|
118
|
+
The client signals only through the `Popen` object of the child it started, so no
|
|
119
|
+
pid it did not start — including one the operating system has since reused —
|
|
120
|
+
can be signalled. That guarantee no longer needs a start-time check to hold,
|
|
121
|
+
because there is no pid on disk to re-authenticate.
|
|
122
|
+
|
|
123
|
+
Per platform:
|
|
124
|
+
|
|
125
|
+
- **Linux** and **macOS**: the child is started in a session of its own
|
|
126
|
+
(`start_new_session=True`), so a `SIGINT` or `SIGHUP` sent to the client's
|
|
127
|
+
process group does not reach it; stdin is what ends it. Other children the
|
|
128
|
+
client spawns do not inherit the write end, since CPython closes descriptors
|
|
129
|
+
across `subprocess` by default, so it has exactly one holder.
|
|
130
|
+
- **Windows**: the operating system closes the same anonymous pipe when the
|
|
131
|
+
owning process exits, however it exits, so the guarantee is unchanged. Windows
|
|
132
|
+
has no `fork()`, so the case below cannot arise there.
|
|
133
|
+
- **`fork()`**: the forked child inherits the write end, which would hold the
|
|
134
|
+
service open past its owner. An `os.register_at_fork` hook therefore disowns
|
|
135
|
+
the inherited services in the new process and closes its copy of the pipe: the
|
|
136
|
+
service stays tied to the process that started it, and a forked child that
|
|
137
|
+
connects starts one of its own.
|
|
138
|
+
|
|
139
|
+
The single limitation is deliberate: a service reached explicitly is not tied to
|
|
140
|
+
the client's lifetime, because the client does not own it.
|
|
141
|
+
|
|
142
|
+
### Cost of a private child
|
|
143
|
+
|
|
144
|
+
Measured on Linux with `python/scripts/measure_private_service.py` (n=20):
|
|
145
|
+
|
|
146
|
+
| | p50 | p95 |
|
|
147
|
+
| ----------------------------------------------------- | ------: | ------: |
|
|
148
|
+
| first connection: spawn, bind, report, handshake | 7.0 ms | 9.1 ms |
|
|
149
|
+
| a later connection joining this interpreter's child | 0.6 ms | 1.0 ms |
|
|
150
|
+
| a child per connection, rather than one shared | 29.6 ms | 54.6 ms |
|
|
151
|
+
| parsing a model the shared child has already parsed | 0.3 ms | 1.2 ms |
|
|
152
|
+
| the same parse in a child of that connection's own | 139.8 ms | 269.6 ms |
|
|
153
|
+
|
|
154
|
+
The last two rows are why the child is per interpreter rather than per
|
|
155
|
+
connection: a child per connection would not only spawn N times, it would parse
|
|
156
|
+
each model N times, against a cache hit some 500x cheaper.
|
|
106
157
|
|
|
107
158
|
## Pinned release digests
|
|
108
159
|
|
|
@@ -4,7 +4,7 @@ Python client for OpenSysML: parse, inspect and execute SysML v2 models over the
|
|
|
4
4
|
`sysml-grpc` service.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
pip install opensysml # from PyPI
|
|
7
|
+
pip install opensysml # from PyPI
|
|
8
8
|
pip install -e python/ # or from a checkout, at the repository root
|
|
9
9
|
```
|
|
10
10
|
|
|
@@ -16,8 +16,7 @@ print(model.eval("1 + 2 * 3")) # 7
|
|
|
16
16
|
|
|
17
17
|
print(model.eval("mass", subject="Demo::sedan")) # 1200.0 — that object, not the default
|
|
18
18
|
# requires the service's evaluate_subject
|
|
19
|
-
# capability
|
|
20
|
-
# service that would ignore the subject
|
|
19
|
+
# capability; the client checks it first
|
|
21
20
|
|
|
22
21
|
vehicle = model["Vehicle"] # by short name or FQN
|
|
23
22
|
vehicle.attributes() # own and inherited, with resolved facts
|
|
@@ -43,22 +42,74 @@ Every call goes through the `sysml-grpc` service, which `opensysml` starts for y
|
|
|
43
42
|
|
|
44
43
|
## Service ownership
|
|
45
44
|
|
|
46
|
-
`opensysml` never stops a service it did not
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
the
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
45
|
+
`opensysml` uses a service of its own, and never stops a service it did not
|
|
46
|
+
start.
|
|
47
|
+
|
|
48
|
+
- A connection made without naming a service starts a **private child** of this
|
|
49
|
+
interpreter. The child binds port 0, so the kernel assigns the port, and it
|
|
50
|
+
reports the address it was given on its stdout — no port is chosen, probed or
|
|
51
|
+
retried by the client, and two interpreters starting at once cannot collide.
|
|
52
|
+
- One private child serves **every** connection of an interpreter that needs the
|
|
53
|
+
same service release. The first of them starts it; it stops when the last one
|
|
54
|
+
closes, or when the interpreter exits. Sharing it shares its parse cache,
|
|
55
|
+
which is what makes a second connection cheap (see below).
|
|
56
|
+
- Its lifetime is this interpreter's. Nothing is recorded on disk about it, no
|
|
57
|
+
other process adopts it, and a service another process left listening is
|
|
58
|
+
neither reused nor cleaned up.
|
|
59
|
+
- Connecting to a service `opensysml` did not start is explicit: pass a host and
|
|
60
|
+
port (`opensysml.connect("localhost", 50051)`, or `connect("localhost:50051")`),
|
|
61
|
+
set `$OPENSYSML_SERVICE=host:port`, or pass `auto_start=False` to require a
|
|
62
|
+
service the caller manages. Closing such a connection leaves it running.
|
|
63
|
+
|
|
64
|
+
### No orphans
|
|
65
|
+
|
|
66
|
+
The client holds the write end of the child's **stdin pipe** and never writes to
|
|
67
|
+
it; the child reads its stdin and shuts down on end of file. Nothing else holds
|
|
68
|
+
that write end, so the pipe closes when the owning process goes away — and it is
|
|
69
|
+
the kernel that closes it, not any code of ours. That survives what an `atexit`
|
|
70
|
+
hook or a supervisor thread does not: `SIGKILL`, `os._exit`, a fatal interpreter
|
|
71
|
+
error, and a crash during shutdown. On an orderly close the client also closes
|
|
72
|
+
stdin itself and then signals the child, so exit is prompt rather than eventual.
|
|
73
|
+
|
|
74
|
+
The client signals only through the `Popen` object of the child it started, so no
|
|
75
|
+
pid it did not start — including one the operating system has since reused —
|
|
76
|
+
can be signalled. That guarantee no longer needs a start-time check to hold,
|
|
77
|
+
because there is no pid on disk to re-authenticate.
|
|
78
|
+
|
|
79
|
+
Per platform:
|
|
80
|
+
|
|
81
|
+
- **Linux** and **macOS**: the child is started in a session of its own
|
|
82
|
+
(`start_new_session=True`), so a `SIGINT` or `SIGHUP` sent to the client's
|
|
83
|
+
process group does not reach it; stdin is what ends it. Other children the
|
|
84
|
+
client spawns do not inherit the write end, since CPython closes descriptors
|
|
85
|
+
across `subprocess` by default, so it has exactly one holder.
|
|
86
|
+
- **Windows**: the operating system closes the same anonymous pipe when the
|
|
87
|
+
owning process exits, however it exits, so the guarantee is unchanged. Windows
|
|
88
|
+
has no `fork()`, so the case below cannot arise there.
|
|
89
|
+
- **`fork()`**: the forked child inherits the write end, which would hold the
|
|
90
|
+
service open past its owner. An `os.register_at_fork` hook therefore disowns
|
|
91
|
+
the inherited services in the new process and closes its copy of the pipe: the
|
|
92
|
+
service stays tied to the process that started it, and a forked child that
|
|
93
|
+
connects starts one of its own.
|
|
94
|
+
|
|
95
|
+
The single limitation is deliberate: a service reached explicitly is not tied to
|
|
96
|
+
the client's lifetime, because the client does not own it.
|
|
97
|
+
|
|
98
|
+
### Cost of a private child
|
|
99
|
+
|
|
100
|
+
Measured on Linux with `python/scripts/measure_private_service.py` (n=20):
|
|
101
|
+
|
|
102
|
+
| | p50 | p95 |
|
|
103
|
+
| ----------------------------------------------------- | ------: | ------: |
|
|
104
|
+
| first connection: spawn, bind, report, handshake | 7.0 ms | 9.1 ms |
|
|
105
|
+
| a later connection joining this interpreter's child | 0.6 ms | 1.0 ms |
|
|
106
|
+
| a child per connection, rather than one shared | 29.6 ms | 54.6 ms |
|
|
107
|
+
| parsing a model the shared child has already parsed | 0.3 ms | 1.2 ms |
|
|
108
|
+
| the same parse in a child of that connection's own | 139.8 ms | 269.6 ms |
|
|
109
|
+
|
|
110
|
+
The last two rows are why the child is per interpreter rather than per
|
|
111
|
+
connection: a child per connection would not only spawn N times, it would parse
|
|
112
|
+
each model N times, against a cache hit some 500x cheaper.
|
|
62
113
|
|
|
63
114
|
## Pinned release digests
|
|
64
115
|
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
import warnings
|
|
4
4
|
|
|
5
5
|
from opensysml._version import VERSION as _declared_version
|
|
6
|
-
from opensysml.connection import
|
|
6
|
+
from opensysml.connection import (
|
|
7
|
+
Connection,
|
|
8
|
+
DEFAULT_PORT,
|
|
9
|
+
named_target,
|
|
10
|
+
split_target,
|
|
11
|
+
)
|
|
7
12
|
from opensysml.model import Model
|
|
8
13
|
from opensysml.symbol import Symbol
|
|
9
14
|
from opensysml.diagnostic import Diagnostic
|
|
@@ -33,11 +38,11 @@ from opensysml.errors import (
|
|
|
33
38
|
RenameReferencedError,
|
|
34
39
|
OwnerNotFoundError, OwnerNotNamespaceError, IllegalMemberKindError,
|
|
35
40
|
MemberNameTakenError, DeleteReferencedError,
|
|
36
|
-
InstanceTypeError, InvalidRequestError, ModelError,
|
|
41
|
+
InstanceTypeError, InvalidRequestError, ManifestSignatureError, ModelError,
|
|
37
42
|
ModelFileNotFoundError, ModelNotFoundError, ServiceError,
|
|
38
43
|
ServiceTimeoutError, StaleServiceError, SymbolNotFoundError,
|
|
39
|
-
TypeMismatchError, UnpinnedReleaseError,
|
|
40
|
-
UnsupportedValueError, WrongKindError,
|
|
44
|
+
TypeMismatchError, UnpinnedReleaseError, UnsignedReleaseError,
|
|
45
|
+
UnsupportedOperationError, UnsupportedValueError, WrongKindError,
|
|
41
46
|
)
|
|
42
47
|
|
|
43
48
|
__all__ = [
|
|
@@ -57,11 +62,12 @@ __all__ = [
|
|
|
57
62
|
"RenameReferencedError", "OverlappingEditsError", "EditResultError",
|
|
58
63
|
"OwnerNotFoundError", "OwnerNotNamespaceError", "IllegalMemberKindError",
|
|
59
64
|
"MemberNameTakenError", "DeleteReferencedError",
|
|
60
|
-
"InstanceTypeError", "InvalidRequestError", "
|
|
65
|
+
"InstanceTypeError", "InvalidRequestError", "ManifestSignatureError",
|
|
66
|
+
"MissingCapabilityError",
|
|
61
67
|
"ModelError", "ModelFileNotFoundError", "ModelNotFoundError",
|
|
62
68
|
"ServiceError", "ServiceTimeoutError", "StaleServiceError",
|
|
63
69
|
"SymbolNotFoundError",
|
|
64
|
-
"TypeMismatchError", "UnpinnedReleaseError",
|
|
70
|
+
"TypeMismatchError", "UnpinnedReleaseError", "UnsignedReleaseError",
|
|
65
71
|
"UnsupportedOperationError", "UnsupportedValueError",
|
|
66
72
|
"WrongKindError",
|
|
67
73
|
"load", "loads", "connect", "convert",
|
|
@@ -84,13 +90,15 @@ _default_connection_params = None
|
|
|
84
90
|
def _get_default_connection(host='localhost', port=None):
|
|
85
91
|
"""Get or create the default module-level connection.
|
|
86
92
|
|
|
87
|
-
If called with different
|
|
93
|
+
If called with a different service than last time, creates new connection.
|
|
88
94
|
A ``host:port`` address written as the host names the same service as the
|
|
89
95
|
two given separately, so it reuses the same connection.
|
|
90
96
|
|
|
91
97
|
Args:
|
|
92
|
-
host (str):
|
|
93
|
-
|
|
98
|
+
host (str): Hostname of an externally managed service, or a
|
|
99
|
+
``host:port`` address naming one; unnamed, the private service this
|
|
100
|
+
interpreter starts is used
|
|
101
|
+
port (int, optional): Port of an externally managed service
|
|
94
102
|
|
|
95
103
|
Returns:
|
|
96
104
|
Connection: Singleton default connection
|
|
@@ -99,12 +107,13 @@ def _get_default_connection(host='localhost', port=None):
|
|
|
99
107
|
ValueError: If host names a port that is unreadable or disagrees with port
|
|
100
108
|
"""
|
|
101
109
|
global _default_connection, _default_connection_params
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
# Keyed by the service named, if any, so the unnamed default is one key
|
|
111
|
+
# rather than one per spelling of it.
|
|
112
|
+
params = named_target(host, port)
|
|
104
113
|
|
|
105
114
|
# Create new connection if params changed or no connection exists
|
|
106
115
|
if _default_connection is None or _default_connection_params != params:
|
|
107
|
-
# Close old connection to avoid
|
|
116
|
+
# Close old connection to avoid holding a service nothing is using
|
|
108
117
|
if _default_connection is not None:
|
|
109
118
|
_default_connection.close()
|
|
110
119
|
_default_connection = Connection(host, port, auto_start=True)
|
|
@@ -163,13 +172,17 @@ def connect(host='localhost', port=None, auto_start=True, version=None,
|
|
|
163
172
|
Convenience function that creates a new Connection instance.
|
|
164
173
|
|
|
165
174
|
Args:
|
|
166
|
-
host (str):
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
host (str): Hostname of an externally managed service, or a
|
|
176
|
+
``host:port`` address naming one. Naming either connects to a
|
|
177
|
+
service this client does not manage; unnamed, and with
|
|
178
|
+
$OPENSYSML_SERVICE unset, a private service is used instead
|
|
179
|
+
(default: 'localhost')
|
|
180
|
+
port (int, optional): Port of an externally managed service
|
|
181
|
+
auto_start (bool): If True, start a private service when no address is
|
|
182
|
+
named. If False, start nothing (default: True)
|
|
170
183
|
version (str, optional): Release tag the service must report, or
|
|
171
184
|
'latest'; defaults to $OPENSYSML_GRPC_VERSION. Checked whether the
|
|
172
|
-
service is
|
|
185
|
+
service is private or externally managed
|
|
173
186
|
require_capabilities (iterable, optional): Capability names the service
|
|
174
187
|
must report, checked at connect time
|
|
175
188
|
|
|
@@ -178,8 +191,8 @@ def connect(host='localhost', port=None, auto_start=True, version=None,
|
|
|
178
191
|
|
|
179
192
|
Raises:
|
|
180
193
|
ValueError: If host names a port that is unreadable or disagrees with port
|
|
181
|
-
|
|
182
|
-
|
|
194
|
+
ConnectionError: If a private service cannot be started
|
|
195
|
+
StaleServiceError: If the service reached is another release
|
|
183
196
|
MissingCapabilityError: If the service lacks a required capability
|
|
184
197
|
|
|
185
198
|
Example:
|
|
@@ -187,7 +200,6 @@ def connect(host='localhost', port=None, auto_start=True, version=None,
|
|
|
187
200
|
>>> conn.port
|
|
188
201
|
50123
|
|
189
202
|
"""
|
|
190
|
-
host, port = split_target(host, port)
|
|
191
203
|
return Connection(
|
|
192
204
|
host, port, auto_start=auto_start, version=version,
|
|
193
205
|
require_capabilities=require_capabilities,
|
|
@@ -12,6 +12,13 @@ from opensysml.errors import (
|
|
|
12
12
|
ChecksumMismatchError,
|
|
13
13
|
ConnectionError,
|
|
14
14
|
UnpinnedReleaseError,
|
|
15
|
+
UnsignedReleaseError,
|
|
16
|
+
)
|
|
17
|
+
from opensysml.signing import (
|
|
18
|
+
BUNDLE_ASSET,
|
|
19
|
+
MANIFEST_ASSET,
|
|
20
|
+
signer_for,
|
|
21
|
+
verified_manifest_digest,
|
|
15
22
|
)
|
|
16
23
|
|
|
17
24
|
# Releases publish sysml-grpc-<goos>-<goarch> raw, with a .sha256 sidecar.
|
|
@@ -71,6 +78,20 @@ PINNED_SHA256 = {
|
|
|
71
78
|
'sysml-grpc-linux-arm64': '6f370241e327dd1277e2b8f35bed4388bae1e4172ff4d2a213c24dee2452536d',
|
|
72
79
|
'sysml-grpc-windows-amd64.exe': '1464a3aca63dd2c4d6cb8639f9958b6d887035cb7a7edd197aaa039fb5ab19f2',
|
|
73
80
|
},
|
|
81
|
+
'v0.2.1': {
|
|
82
|
+
'sysml-grpc-darwin-amd64': '589c6e248f69bc373c536d05379b9339d0c77443e67682aa06fc045dadc0606a',
|
|
83
|
+
'sysml-grpc-darwin-arm64': 'f0397b0b1b9219470a1346d4e4a9135a3e2c3e480951e5aa54a55e593adb7214',
|
|
84
|
+
'sysml-grpc-linux-amd64': 'b1885a0c956a69201f7d00d6d1a4f19f9df12831cedcee8ad4682a9918ffd8a6',
|
|
85
|
+
'sysml-grpc-linux-arm64': '3451bb569c1c2993a5c648df27cadcec900d46e6e1d957f87aac979c8a16c9e5',
|
|
86
|
+
'sysml-grpc-windows-amd64.exe': '3a630d38c2691a27c8f6e3b781f48e05b727c559b219b9c2e7bd8651c222628d',
|
|
87
|
+
},
|
|
88
|
+
'v0.3.0': {
|
|
89
|
+
'sysml-grpc-darwin-amd64': '8c034172f604ec78c6c045b8eef0ddb2fdebac4f22852480ad3289a3e940cf14',
|
|
90
|
+
'sysml-grpc-darwin-arm64': 'c9f8fb0a424277cc83ab3f388e11e54fcc9328b9a2cb86a911e7b96fb6cb316a',
|
|
91
|
+
'sysml-grpc-linux-amd64': 'b39b720e020c325020af95c33640ad3583c938d74c40d29a0741fab6c03412de',
|
|
92
|
+
'sysml-grpc-linux-arm64': '2faeacdbc3dcd20053b2dc79b559bbbf2f53b7470d982b9908c7665a53b37462',
|
|
93
|
+
'sysml-grpc-windows-amd64.exe': 'f50f1b53008154c1a5fb39eb56f09464dedcf081037af765978304b2f33615c6',
|
|
94
|
+
},
|
|
74
95
|
},
|
|
75
96
|
}
|
|
76
97
|
|
|
@@ -123,35 +144,51 @@ def unpinned_downloads_allowed(github_repo=None):
|
|
|
123
144
|
return repo in [named.strip() for named in allowed.split(',')]
|
|
124
145
|
|
|
125
146
|
|
|
126
|
-
def expected_digest(version, asset, served_digest, github_repo=None
|
|
127
|
-
|
|
147
|
+
def expected_digest(version, asset, served_digest, github_repo=None,
|
|
148
|
+
verified_digest=None, unverified_reason=None):
|
|
149
|
+
"""The digest a download must have, from the pin or the signed manifest.
|
|
150
|
+
|
|
151
|
+
The pin wins wherever there is one, and a verified manifest contradicting it
|
|
152
|
+
is a failure rather than a reason to prefer either. Where there is no pin, a
|
|
153
|
+
digest taken from a manifest signed by the release pipeline stands in for
|
|
154
|
+
one; nothing else does.
|
|
128
155
|
|
|
129
156
|
Args:
|
|
130
157
|
version (str): Release tag, resolved (never 'latest')
|
|
131
158
|
asset (str): Asset name (e.g. 'sysml-grpc-linux-amd64')
|
|
132
159
|
served_digest (str): Digest from the .sha256 served beside the binary
|
|
133
160
|
github_repo (str, optional): GitHub repository (owner/repo)
|
|
161
|
+
verified_digest (str, optional): Digest from the release's signed
|
|
162
|
+
checksum manifest, once its signature verified
|
|
163
|
+
unverified_reason (str, optional): Why there is no verified digest, said
|
|
164
|
+
in the refusal when there is no pin either
|
|
134
165
|
|
|
135
166
|
Returns:
|
|
136
167
|
str: The digest to verify the download against
|
|
137
168
|
|
|
138
169
|
Raises:
|
|
139
170
|
ChecksumMismatchError: If the served digest contradicts the pinned one,
|
|
140
|
-
which is a release republished with another binary
|
|
141
|
-
|
|
142
|
-
|
|
171
|
+
which is a release republished with another binary, or if a verified
|
|
172
|
+
manifest contradicts the pin
|
|
173
|
+
UnpinnedReleaseError: If nothing is pinned for it, no signed manifest
|
|
174
|
+
vouched for it, and same-origin trust was not allowed explicitly
|
|
143
175
|
"""
|
|
144
176
|
repo = github_repo or default_github_repo()
|
|
145
177
|
pinned = pinned_digest(version, asset, repo)
|
|
146
178
|
if pinned is None:
|
|
179
|
+
if verified_digest is not None:
|
|
180
|
+
# Signed by the pipeline that built the release, so it is not the
|
|
181
|
+
# origin vouching for itself: as good as a pin.
|
|
182
|
+
return verified_digest
|
|
147
183
|
if not unpinned_downloads_allowed(repo):
|
|
184
|
+
unverified = f" and {unverified_reason}" if unverified_reason else ""
|
|
148
185
|
raise UnpinnedReleaseError(
|
|
149
|
-
f"opensysml pins no SHA-256 digest for {asset} of {version} of {repo}
|
|
150
|
-
f"the only checksum available is the one served beside
|
|
151
|
-
f"a compromised release would serve too. Upgrade
|
|
152
|
-
f"that pins {version}, ask for a pinned release
|
|
153
|
-
f"same-origin trust for this repository by
|
|
154
|
-
f"${ALLOW_UNPINNED_ENV}={repo} (or =1 for any repository)."
|
|
186
|
+
f"opensysml pins no SHA-256 digest for {asset} of {version} of {repo}"
|
|
187
|
+
f"{unverified}, so the only checksum available is the one served beside "
|
|
188
|
+
f"the binary, which a compromised release would serve too. Upgrade "
|
|
189
|
+
f"opensysml to a release that pins {version}, ask for a pinned release "
|
|
190
|
+
f"with version=, or accept same-origin trust for this repository by "
|
|
191
|
+
f"setting ${ALLOW_UNPINNED_ENV}={repo} (or =1 for any repository)."
|
|
155
192
|
)
|
|
156
193
|
warnings.warn(
|
|
157
194
|
f"opensysml pins no digest for {asset} of {version} of {repo}; verifying it "
|
|
@@ -161,6 +198,12 @@ def expected_digest(version, asset, served_digest, github_repo=None):
|
|
|
161
198
|
stacklevel=3,
|
|
162
199
|
)
|
|
163
200
|
return served_digest
|
|
201
|
+
if verified_digest is not None and verified_digest != pinned:
|
|
202
|
+
raise ChecksumMismatchError(
|
|
203
|
+
f"Checksum mismatch for {asset} of {version}: the signed {MANIFEST_ASSET} "
|
|
204
|
+
f"of {repo} lists {verified_digest}, but opensysml pins {pinned}. The "
|
|
205
|
+
f"release was rebuilt after this opensysml pinned it; it was not installed."
|
|
206
|
+
)
|
|
164
207
|
if served_digest != pinned:
|
|
165
208
|
raise ChecksumMismatchError(
|
|
166
209
|
f"Checksum mismatch for {asset} of {version}: {repo} serves {served_digest}, "
|
|
@@ -170,6 +213,62 @@ def expected_digest(version, asset, served_digest, github_repo=None):
|
|
|
170
213
|
return pinned
|
|
171
214
|
|
|
172
215
|
|
|
216
|
+
def release_download_url(version, asset, github_repo=None):
|
|
217
|
+
"""URL a release publishes an asset at.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
version (str): Release tag, resolved (never 'latest')
|
|
221
|
+
asset (str): Asset name (e.g. 'SHA256SUMS.txt')
|
|
222
|
+
github_repo (str, optional): GitHub repository (owner/repo)
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
str: Download URL
|
|
226
|
+
"""
|
|
227
|
+
repo = github_repo or default_github_repo()
|
|
228
|
+
return f'https://github.com/{repo}/releases/download/{version}/{asset}'
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def signed_manifest_digest(version, asset, github_repo=None):
|
|
232
|
+
"""The digest for an asset from the release's signed checksum manifest.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
version (str): Release tag, resolved (never 'latest')
|
|
236
|
+
asset (str): Asset name (e.g. 'sysml-grpc-linux-amd64')
|
|
237
|
+
github_repo (str, optional): GitHub repository (owner/repo)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
str: SHA-256 hex digest, from a manifest the release pipeline signed
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
UnsignedReleaseError: If the release publishes no signature this client
|
|
244
|
+
can check, or nothing could be verified
|
|
245
|
+
ManifestSignatureError: If the signature does not verify
|
|
246
|
+
"""
|
|
247
|
+
repo = github_repo or default_github_repo()
|
|
248
|
+
signer = signer_for(repo)
|
|
249
|
+
if signer is None:
|
|
250
|
+
raise UnsignedReleaseError(
|
|
251
|
+
f"opensysml knows no release pipeline identity for {repo}, so a signed "
|
|
252
|
+
f"{MANIFEST_ASSET} of it would not be verifiable"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
downloaded = {}
|
|
256
|
+
for name in (MANIFEST_ASSET, BUNDLE_ASSET):
|
|
257
|
+
url = release_download_url(version, name, repo)
|
|
258
|
+
try:
|
|
259
|
+
with urllib.request.urlopen(url, timeout=NETWORK_TIMEOUT) as response:
|
|
260
|
+
downloaded[name] = response.read()
|
|
261
|
+
except (OSError, http.client.HTTPException) as e:
|
|
262
|
+
raise UnsignedReleaseError(
|
|
263
|
+
f"{version} of {repo} publishes no readable {name} ({url}: {e}), so "
|
|
264
|
+
f"its checksums carry no signature to verify"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return verified_manifest_digest(
|
|
268
|
+
downloaded[MANIFEST_ASSET], downloaded[BUNDLE_ASSET], asset, signer
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
173
272
|
def resolve_latest_version(github_repo=None):
|
|
174
273
|
"""Resolve the tag of the repository's latest published release.
|
|
175
274
|
|
|
@@ -380,9 +479,11 @@ def download_binary(version='latest', github_repo=None):
|
|
|
380
479
|
str: Path to downloaded binary
|
|
381
480
|
|
|
382
481
|
Raises:
|
|
383
|
-
ChecksumMismatchError: If the download does not match the digest
|
|
384
|
-
for it,
|
|
385
|
-
|
|
482
|
+
ChecksumMismatchError: If the download does not match the digest expected
|
|
483
|
+
for it, the release serves a digest contradicting the pin, or the
|
|
484
|
+
signature on its checksum manifest does not verify
|
|
485
|
+
UnpinnedReleaseError: If nothing is pinned for the release and its
|
|
486
|
+
checksum manifest carries no signature that could be verified
|
|
386
487
|
ConnectionError: If the download or its installation fails
|
|
387
488
|
"""
|
|
388
489
|
github_repo = github_repo or default_github_repo()
|
|
@@ -392,9 +493,10 @@ def download_binary(version='latest', github_repo=None):
|
|
|
392
493
|
binary_name = release_asset_name()
|
|
393
494
|
|
|
394
495
|
# Construct URLs
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
496
|
+
binary_url = release_download_url(version, binary_name, github_repo)
|
|
497
|
+
checksum_url = release_download_url(
|
|
498
|
+
version, binary_name + '.sha256', github_repo
|
|
499
|
+
)
|
|
398
500
|
|
|
399
501
|
binary_path = get_binary_path()
|
|
400
502
|
os.makedirs(os.path.dirname(binary_path), exist_ok=True)
|
|
@@ -406,10 +508,22 @@ def download_binary(version='latest', github_repo=None):
|
|
|
406
508
|
|
|
407
509
|
# Parse checksum (format: "hexdigest filename\n")
|
|
408
510
|
served_checksum = checksum_content.split()[0]
|
|
511
|
+
# A release nothing is pinned for is vouched for by the signature on its
|
|
512
|
+
# checksum manifest instead, which the origin cannot forge.
|
|
513
|
+
verified_checksum = unverified_reason = None
|
|
514
|
+
if pinned_digest(version, binary_name, github_repo) is None:
|
|
515
|
+
try:
|
|
516
|
+
verified_checksum = signed_manifest_digest(
|
|
517
|
+
version, binary_name, github_repo
|
|
518
|
+
)
|
|
519
|
+
except UnsignedReleaseError as e:
|
|
520
|
+
unverified_reason = str(e)
|
|
409
521
|
# The pinned digest wins, so a release republished with another binary
|
|
410
522
|
# cannot vouch for itself through the sidecar it serves.
|
|
411
523
|
expected_checksum = expected_digest(
|
|
412
|
-
version, binary_name, served_checksum, github_repo
|
|
524
|
+
version, binary_name, served_checksum, github_repo,
|
|
525
|
+
verified_digest=verified_checksum,
|
|
526
|
+
unverified_reason=unverified_reason,
|
|
413
527
|
)
|
|
414
528
|
|
|
415
529
|
# Download binary
|