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.
Files changed (76) hide show
  1. {opensysml-0.3.0 → opensysml-0.3.2}/PKG-INFO +73 -22
  2. {opensysml-0.3.0 → opensysml-0.3.2}/README.md +70 -19
  3. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/__init__.py +32 -20
  4. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/_version.py +1 -1
  5. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/binary.py +132 -18
  6. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/capabilities.py +7 -11
  7. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/connection.py +388 -638
  8. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/errors.py +36 -6
  9. opensysml-0.3.2/opensysml/signing.py +308 -0
  10. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/typed.py +14 -5
  11. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/PKG-INFO +73 -22
  12. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/SOURCES.txt +2 -0
  13. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/requires.txt +2 -2
  14. {opensysml-0.3.0 → opensysml-0.3.2}/pyproject.toml +6 -2
  15. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_api.py +4 -4
  16. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_binary.py +7 -2
  17. opensysml-0.3.2/tests/test_connection.py +386 -0
  18. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_conversion.py +18 -1
  19. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_errors.py +13 -0
  20. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_evaluate_subject.py +1 -1
  21. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_generate_golden.py +6 -5
  22. opensysml-0.3.2/tests/test_lifecycle.py +401 -0
  23. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_model_surface_integration.py +1 -1
  24. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_quantity.py +5 -6
  25. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_runtime_integration.py +14 -14
  26. opensysml-0.3.2/tests/test_signing.py +523 -0
  27. opensysml-0.3.2/tests/test_stale_service.py +380 -0
  28. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_target.py +2 -1
  29. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_typed.py +26 -0
  30. opensysml-0.3.0/tests/test_connection.py +0 -585
  31. opensysml-0.3.0/tests/test_lifecycle.py +0 -364
  32. opensysml-0.3.0/tests/test_stale_service.py +0 -727
  33. {opensysml-0.3.0 → opensysml-0.3.2}/LICENSE +0 -0
  34. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/_dist.py +0 -0
  35. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/conversion.py +0 -0
  36. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/diagnostic.py +0 -0
  37. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/edit.py +0 -0
  38. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/enumeration.py +0 -0
  39. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/generate.py +0 -0
  40. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/instance.py +0 -0
  41. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/model.py +0 -0
  42. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/__init__.py +0 -0
  43. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/sysml_pb2.py +0 -0
  44. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/sysml_pb2.pyi +0 -0
  45. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/proto/sysml_pb2_grpc.py +0 -0
  46. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/py.typed +0 -0
  47. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/query.py +0 -0
  48. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/symbol.py +0 -0
  49. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/typefacts.py +0 -0
  50. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/values.py +0 -0
  51. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml/verdict.py +0 -0
  52. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/dependency_links.txt +0 -0
  53. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/entry_points.txt +0 -0
  54. {opensysml-0.3.0 → opensysml-0.3.2}/opensysml.egg-info/top_level.txt +0 -0
  55. {opensysml-0.3.0 → opensysml-0.3.2}/setup.cfg +0 -0
  56. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_capabilities.py +0 -0
  57. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_diagnostic.py +0 -0
  58. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_edit.py +0 -0
  59. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_enumeration.py +0 -0
  60. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_feature_values.py +0 -0
  61. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_feature_values_capability.py +0 -0
  62. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_generate.py +0 -0
  63. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_instance.py +0 -0
  64. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_integration.py +0 -0
  65. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_legacy_pysysml_placeholder.py +0 -0
  66. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_model.py +0 -0
  67. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_pin_release_checksums.py +0 -0
  68. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_query.py +0 -0
  69. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_runtime.py +0 -0
  70. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_strict_and_lookup.py +0 -0
  71. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_strict_conformance.py +0 -0
  72. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_symbol.py +0 -0
  73. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_unset.py +0 -0
  74. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_verification.py +0 -0
  75. {opensysml-0.3.0 → opensysml-0.3.2}/tests/test_version.py +0 -0
  76. {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.0
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: filelock>=3.0.0
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, once the first release is published
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, rather than trusting a
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 start.
91
-
92
- - A connection that finds a healthy service already listening uses it and takes
93
- no ownership of it: nothing is recorded, and closing the connection leaves the
94
- service running. Whoever started it decides when it stops.
95
- - A service `opensysml` starts is recorded in
96
- `~/.opensysml/sysml-grpc-<port>.pid` (`$OPENSYSML_STATE_DIR` overrides the
97
- directory) as the service's pid and process start time, plus the pid and start
98
- time of the process that started it. Only that process stops it, and only when
99
- the last connection holding it is closed or the interpreter exits.
100
- - The start times are what authenticate the record: a pid is re-checked against
101
- the start time written for it, so a pid the operating system has since reused
102
- is treated as a stale record — cleaned up, never signalled. A command line
103
- that merely looks like `sysml-grpc` is not identity and is never acted on.
104
- - A service that crashes leaves a record whose process is gone; the next
105
- connection detects that, removes it and starts a service of its own.
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, once the first release is published
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, rather than trusting a
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 start.
47
-
48
- - A connection that finds a healthy service already listening uses it and takes
49
- no ownership of it: nothing is recorded, and closing the connection leaves the
50
- service running. Whoever started it decides when it stops.
51
- - A service `opensysml` starts is recorded in
52
- `~/.opensysml/sysml-grpc-<port>.pid` (`$OPENSYSML_STATE_DIR` overrides the
53
- directory) as the service's pid and process start time, plus the pid and start
54
- time of the process that started it. Only that process stops it, and only when
55
- the last connection holding it is closed or the interpreter exits.
56
- - The start times are what authenticate the record: a pid is re-checked against
57
- the start time written for it, so a pid the operating system has since reused
58
- is treated as a stale record — cleaned up, never signalled. A command line
59
- that merely looks like `sysml-grpc` is not identity and is never acted on.
60
- - A service that crashes leaves a record whose process is gone; the next
61
- connection detects that, removes it and starts a service of its own.
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 Connection, DEFAULT_PORT, split_target
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, UnsupportedOperationError,
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", "MissingCapabilityError",
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 host/port than last time, creates new connection.
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): Service hostname, or a ``host:port`` address
93
- port (int, optional): Service port (default: 50051)
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
- host, port = split_target(host, port)
103
- params = (host, port)
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 refcount leak
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): Service hostname, or a ``host:port`` address, whose port is
167
- used when no separate port is given (default: 'localhost')
168
- port (int, optional): Service port (default: 50051)
169
- auto_start (bool): If True, automatically start service if not running (default: True)
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 started here or managed by the caller
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
- StaleServiceError: If another release is already listening on the
182
- address and this client may not stop it
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,
@@ -6,4 +6,4 @@ neither is a second copy that can drift. `scripts/check_version.py` compares
6
6
  this value with the release tag before anything is uploaded.
7
7
  """
8
8
 
9
- VERSION = "0.3.0"
9
+ VERSION = "0.3.2"
@@ -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
- """The digest a download must have, preferring the pin over what was served.
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
- UnpinnedReleaseError: If nothing is pinned for it and same-origin trust
142
- was not allowed explicitly
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}, so "
150
- f"the only checksum available is the one served beside the binary, which "
151
- f"a compromised release would serve too. Upgrade opensysml to a release "
152
- f"that pins {version}, ask for a pinned release with version=, or accept "
153
- f"same-origin trust for this repository by setting "
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 pinned
384
- for it, or the release serves a digest contradicting the pin
385
- UnpinnedReleaseError: If nothing is pinned for the release
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
- base_url = f'https://github.com/{github_repo}/releases/download/{version}'
396
- binary_url = f'{base_url}/{binary_name}'
397
- checksum_url = f'{base_url}/{binary_name}.sha256'
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