protoplasm6 6.1.0__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 (59) hide show
  1. protoplasm6-6.1.0/LICENSE +22 -0
  2. protoplasm6-6.1.0/PKG-INFO +402 -0
  3. protoplasm6-6.1.0/README.md +366 -0
  4. protoplasm6-6.1.0/protoplasm6/__init__.py +5 -0
  5. protoplasm6-6.1.0/protoplasm6/bases/__init__.py +3 -0
  6. protoplasm6-6.1.0/protoplasm6/bases/_client.py +83 -0
  7. protoplasm6-6.1.0/protoplasm6/bases/_exwrap.py +113 -0
  8. protoplasm6-6.1.0/protoplasm6/bases/_metadata.py +89 -0
  9. protoplasm6-6.1.0/protoplasm6/bases/_methwrap.py +220 -0
  10. protoplasm6-6.1.0/protoplasm6/bases/_remotewrap.py +268 -0
  11. protoplasm6-6.1.0/protoplasm6/bases/_servicer.py +46 -0
  12. protoplasm6-6.1.0/protoplasm6/bases/dataclass_bases.py +2 -0
  13. protoplasm6-6.1.0/protoplasm6/bases/grpc_bases.py +4 -0
  14. protoplasm6-6.1.0/protoplasm6/casting/__init__.py +5 -0
  15. protoplasm6-6.1.0/protoplasm6/casting/casters.py +89 -0
  16. protoplasm6-6.1.0/protoplasm6/casting/castutils.py +379 -0
  17. protoplasm6-6.1.0/protoplasm6/casting/converters/__init__.py +128 -0
  18. protoplasm6-6.1.0/protoplasm6/casting/converters/_base.py +39 -0
  19. protoplasm6-6.1.0/protoplasm6/casting/converters/_dict.py +39 -0
  20. protoplasm6-6.1.0/protoplasm6/casting/converters/_direct.py +461 -0
  21. protoplasm6-6.1.0/protoplasm6/casting/dictator.py +85 -0
  22. protoplasm6-6.1.0/protoplasm6/casting/dictators/__init__.py +375 -0
  23. protoplasm6-6.1.0/protoplasm6/casting/objectifier.py +100 -0
  24. protoplasm6-6.1.0/protoplasm6/decorators/__init__.py +1 -0
  25. protoplasm6-6.1.0/protoplasm6/decorators/apihelpers.py +100 -0
  26. protoplasm6-6.1.0/protoplasm6/errors/__init__.py +4 -0
  27. protoplasm6-6.1.0/protoplasm6/errors/_api.py +238 -0
  28. protoplasm6-6.1.0/protoplasm6/errors/_base.py +31 -0
  29. protoplasm6-6.1.0/protoplasm6/grpcserver.py +53 -0
  30. protoplasm6-6.1.0/protoplasm6/loader/__init__.py +17 -0
  31. protoplasm6-6.1.0/protoplasm6/plasm/__init__.py +7 -0
  32. protoplasm6-6.1.0/protoplasm6/structs/__init__.py +8 -0
  33. protoplasm6-6.1.0/protoplasm6/structs/_base.py +4 -0
  34. protoplasm6-6.1.0/protoplasm6/structs/_ctx/__init__.py +2 -0
  35. protoplasm6-6.1.0/protoplasm6/structs/_ctx/_basectx.py +15 -0
  36. protoplasm6-6.1.0/protoplasm6/structs/_ctx/_grpcctx.py +53 -0
  37. protoplasm6-6.1.0/protoplasm6/structs/_methodtype.py +33 -0
  38. protoplasm6-6.1.0/protoplasm6/structs/_oneofs.py +95 -0
  39. protoplasm6-6.1.0/protoplasm6/structs/_reqestsiter.py +32 -0
  40. protoplasm6-6.1.0/protoplasm6/structs/_responseiter.py +172 -0
  41. protoplasm6-6.1.0/protoplasm6/structs/dataclassbase.py +228 -0
  42. protoplasm6-6.1.0/protoplasm6.egg-info/PKG-INFO +402 -0
  43. protoplasm6-6.1.0/protoplasm6.egg-info/SOURCES.txt +57 -0
  44. protoplasm6-6.1.0/protoplasm6.egg-info/dependency_links.txt +1 -0
  45. protoplasm6-6.1.0/protoplasm6.egg-info/requires.txt +6 -0
  46. protoplasm6-6.1.0/protoplasm6.egg-info/top_level.txt +1 -0
  47. protoplasm6-6.1.0/pyproject.toml +61 -0
  48. protoplasm6-6.1.0/setup.cfg +4 -0
  49. protoplasm6-6.1.0/setup.py +6 -0
  50. protoplasm6-6.1.0/tests/test_call_metadata.py +117 -0
  51. protoplasm6-6.1.0/tests/test_casting.py +675 -0
  52. protoplasm6-6.1.0/tests/test_castutils.py +803 -0
  53. protoplasm6-6.1.0/tests/test_converters.py +341 -0
  54. protoplasm6-6.1.0/tests/test_dataclasses.py +136 -0
  55. protoplasm6-6.1.0/tests/test_dictator.py +506 -0
  56. protoplasm6-6.1.0/tests/test_dictators.py +390 -0
  57. protoplasm6-6.1.0/tests/test_objectifier.py +754 -0
  58. protoplasm6-6.1.0/tests/test_oneofs_and_dicts.py +135 -0
  59. protoplasm6-6.1.0/tests/test_services.py +263 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thordur Matthiasson
4
+ Copyright (c) 2019-2024 CCP Games
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,402 @@
1
+ Metadata-Version: 2.4
2
+ Name: protoplasm6
3
+ Version: 6.1.0
4
+ Summary: Utilities for working with Protobuf & gRPC in Python (a continuation fork of CCP Games' protoplasm).
5
+ Author-email: Thordur Matthiasson <doddi79@gmail.com>, Daniel Maxson <dmaxson@ccpgames.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/doddi79/protoplasm6
8
+ Project-URL: Documentation, https://github.com/doddi79/protoplasm6/blob/main/README.md
9
+ Project-URL: Repository, https://github.com/doddi79/protoplasm6.git
10
+ Project-URL: Issues, https://github.com/doddi79/protoplasm6/issues
11
+ Project-URL: Changelog, https://github.com/doddi79/protoplasm6/blob/main/CHANGELOG.md
12
+ Keywords: protobuf,proto,grpc,dataclasses,neobuf,protoplasm,tools,utils
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Topic :: Utilities
27
+ Requires-Python: >=3.10
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: batutils<3,>=2.2
31
+ Requires-Dist: protobuf<8,>=7.34
32
+ Requires-Dist: grpcio<2,>=1.80
33
+ Provides-Extra: googleapis
34
+ Requires-Dist: googleapis-common-protos<2,>=1.70; extra == "googleapis"
35
+ Dynamic: license-file
36
+
37
+ # Protoplasm6
38
+
39
+ Utilities for working with Protobuf & gRPC in Python: Python 3 dataclasses
40
+ generated from compiled protobuf messages and casting between the two, quickly
41
+ initializing large/nested Protobuf objects, and simplified gRPC service
42
+ interfaces where you implement plain Python methods with plain Python arguments
43
+ and return values instead of fiddling with request/response messages.
44
+
45
+ See the **[Public API index](docs/api-index.md)** for a task-oriented reference of
46
+ everything protoplasm6 offers.
47
+
48
+ ## Note
49
+
50
+ This is a fork of the [`protoplasm`](https://github.com/ccpgames/protoplasm)
51
+ package (v5.3.1) by CCP Games, made in order to continue its development and
52
+ maintenance. The package _and_ module are now called `protoplasm6` (starting
53
+ at version 6.0.0) so it can live side-by-side with the original.
54
+
55
+ ## Protoplasm6 & Neobuilder6
56
+
57
+ Protoplasm6 is the **runtime** half of a pair. The other half is
58
+ [`neobuilder6`](https://github.com/doddi79/neobuilder6), the **build-time**
59
+ code generator:
60
+
61
+ ```
62
+ neobuilder6 (build time) protoplasm6 (run time)
63
+ foo.proto ──protoc──► foo_pb2.py, foo_pb2_grpc.py
64
+ └─► foo_dc.py (dataclasses) ──► protoplasm6.plasm.DataclassBase
65
+ └─► foo_api.py (interfaces) ──► protoplasm6.plasm errors/decorators
66
+ └─► foo_grpc_receiver.py (servicers) ──► protoplasm6.plasm.BaseGrpcServicer
67
+ └─► foo_grpc_sender.py (clients) ──► protoplasm6.plasm.BaseGrpcClientImplementation
68
+ ```
69
+
70
+ - Neobuilder6 depends on Protoplasm6 (it uses some of its utilities, and the
71
+ code it generates imports `protoplasm6`).
72
+ - Protoplasm6 does **not** depend on Neobuilder6 at runtime, but its unit
73
+ tests use Neobuilder6 to build the test protos (so Neobuilder6 is installed
74
+ with `--no-deps` for testing).
75
+
76
+ The major and minor versions of the two go hand in hand, so code built with
77
+ Neobuilder6 `6.x` should be run with Protoplasm6 `6.x` (where Protoplasm6's
78
+ minor version is the same or higher).
79
+
80
+ ## Installation
81
+
82
+ ```
83
+ pip install protoplasm6
84
+ ```
85
+
86
+ ...although you'll most likely want [`neobuilder6`](https://github.com/doddi79/neobuilder6)
87
+ as well to build your proto files, which pulls in protoplasm6 for you:
88
+
89
+ ```
90
+ pip install neobuilder6
91
+ ```
92
+
93
+ Protoplasm6 requires Python 3.10+ and depends on `protobuf` 7.x, `grpcio` and
94
+ [`batutils`](https://github.com/doddi79/batutils).
95
+
96
+ Note that `*_pb2.py` files generated by `protoc` (i.e. Neobuilder6) check
97
+ the version of the `protobuf` runtime when imported, so the `protobuf` version
98
+ used to run a generated package must be the same major version and at least
99
+ the same minor version as the `grpcio-tools` that generated it.
100
+
101
+ ## The idea
102
+
103
+ Design your whole system of services and messages in proto files, and
104
+ implement each service as a plain Python class with plain Python methods,
105
+ arguments and return values. The generated client for a service implements the
106
+ exact same interface as the service itself, so the code calling a service
107
+ can't tell (and doesn't care) whether it's talking to a local object in the
108
+ same process or a remote gRPC service.
109
+
110
+ That means you can start with a monolith where everything runs in the same
111
+ runtime, and later pick out any service "wholesale" and move it to its own
112
+ remote process, swapping the local implementation for the generated gRPC
113
+ client, without changing the "core code" on either side. No refactoring
114
+ needed, and nothing in your business logic needs to know what a "Request",
115
+ "Response", protobuf or even protoplasm is.
116
+
117
+ ## Usage
118
+
119
+ The examples below assume a proto file like this one, at
120
+ `./proto/myshop/orders/orders.proto`, built by Neobuilder6 (see its README):
121
+
122
+ ```protobuf
123
+ syntax = "proto3";
124
+
125
+ package myshop.orders;
126
+
127
+ import "google/protobuf/timestamp.proto";
128
+
129
+ service OrderService {
130
+ rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) {}
131
+ rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse) {}
132
+ }
133
+
134
+ message Customer {
135
+ string customer_id = 1;
136
+ string name = 2;
137
+ }
138
+
139
+ message Order {
140
+ string order_id = 1;
141
+ Customer customer = 2;
142
+ repeated string items = 3;
143
+ google.protobuf.Timestamp placed_at = 4;
144
+ }
145
+
146
+ message GetOrderRequest { string order_id = 1; }
147
+ message GetOrderResponse { Order order = 1; }
148
+
149
+ message ListOrdersRequest { string customer_id = 1; }
150
+ message ListOrdersResponse { repeated Order orders = 1; }
151
+ ```
152
+
153
+ That gives you the modules `orders_pb2`, `orders_pb2_grpc`, `orders_dc`,
154
+ `orders_api`, `orders_grpc_receiver` and `orders_grpc_sender` in the
155
+ `myshop.orders` package.
156
+
157
+ ### Dataclasses
158
+
159
+ Every proto message gets a dataclass in the `*_dc.py` module that extends
160
+ `protoplasm6.plasm.DataclassBase`:
161
+
162
+ ```python
163
+ import datetime
164
+ from myshop.orders.orders_dc import *
165
+
166
+ order = Order(order_id='o1', customer=Customer(customer_id='c1', name='Jane'),
167
+ items=['apple', 'pear'], placed_at=datetime.datetime.now())
168
+
169
+ order.to_dict() # Nested dict of JSON serializable values
170
+ order.to_json()
171
+ order.to_proto() # The orders_pb2.Order protobuf message
172
+ order.to_protostr() # Serialized protobuf bytes
173
+ order.to_base64() # Serialized protobuf as base64
174
+
175
+ Order.from_dict({'order_id': 'o1', 'customer': {'customer_id': 'c1'}})
176
+ Order.from_json(...)
177
+ Order.from_proto(...)
178
+ Order.from_protostr(...)
179
+ Order.from_base64(...)
180
+ Order.from_kwdict(order_id='o1', customer__name='Jane') # Double underscores for nesting
181
+ ```
182
+
183
+ Nested messages can be given as dicts, lists/tuples (as positional args) or
184
+ dataclass instances and will be forced into the correct dataclass type.
185
+ `google.protobuf.Timestamp` and `Duration` fields are `datetime.datetime` and
186
+ `datetime.timedelta`, `Any` fields hold any dataclass, and `Struct`/`Value`
187
+ fields hold plain dicts/values.
188
+
189
+ Dataclasses of messages with `oneof` groups behave like protobuf messages:
190
+ setting one member resets the others, and you can ask which one is set:
191
+
192
+ ```python
193
+ payment.which_oneof('method') # e.g. 'card_number' or None
194
+ payment.get_oneof('method') # The value of the member that's set (or None)
195
+ payment.clear_oneof('method')
196
+ ```
197
+
198
+ ### Casting utilities
199
+
200
+ ```python
201
+ from protoplasm6 import casting
202
+ from myshop.orders import orders_pb2
203
+
204
+ casting.mkproto(orders_pb2.Order, order_id='o1', customer__name='Jane')
205
+ casting.mkdataclass(Order, order_id='o1', customer__name='Jane')
206
+ casting.proto_to_dataclass(some_proto)
207
+ casting.dataclass_to_proto(some_dataclass)
208
+ casting.proto_to_dict(some_proto)
209
+ casting.dict_to_proto(orders_pb2.Order, {...})
210
+ casting.dict_to_dataclass(Order, {...})
211
+ ```
212
+
213
+ ### Converters (protobuf <-> dataclass)
214
+
215
+ Whenever protoplasm6 casts between protobuf messages and dataclasses (e.g.
216
+ `to_proto()`, `from_proto()`, `casting.proto_to_dataclass()`,
217
+ `casting.dataclass_to_proto()` and in every gRPC call) it uses the current
218
+ **converter**. There are two:
219
+
220
+ | Name | Class | What it does |
221
+ |--------------------|-------------------|--------------------------------------------------------------------------------------------------------------|
222
+ | `direct` (default) | `DirectConverter` | Converts directly between messages and dataclasses using the protobuf message API |
223
+ | `dict` | `DictConverter` | The original (protoplasm 5.x) conversion: protobuf <-> `json_format` dict <-> dataclass |
224
+
225
+ The `direct` converter is roughly 4-7 times faster (both ways, for small and
226
+ large messages alike) and also gets a few things right that the `dict` one
227
+ gets wrong (see the CHANGELOG), e.g. `Dict[int, ...]` maps keep
228
+ their `int` keys, `Dict[bool, ...]` maps work, timezone aware `datetime`
229
+ values are converted to UTC properly and messages with `oneof` scalar fields
230
+ work.
231
+
232
+ Fields it doesn't know how to handle (e.g. ones with your own custom
233
+ dictators) fall back to the `dict` logic for that field only.
234
+
235
+ You can switch converters globally, temporarily or via an environment
236
+ variable:
237
+
238
+ ```python
239
+ from protoplasm6.casting import converters
240
+
241
+ converters.set_converter('dict') # Globally
242
+
243
+ with converters.use_converter('dict'): # Only within this block (and context/thread)
244
+ proto = order.to_proto()
245
+
246
+ converters.get_converter() # The converter currently in use
247
+
248
+ converters.DictConverter().dataclass_to_proto(order) # Or use one explicitly
249
+ ```
250
+
251
+ ```
252
+ PROTOPLASM6_CONVERTER=dict python my_service.py
253
+ ```
254
+
255
+ You can also roll your own by extending `converters.BaseConverter` and
256
+ registering it with `converters.register_converter(...)`.
257
+
258
+ ### Services
259
+
260
+ For every `service` in a proto file you get a `*Interface` class in the
261
+ `*_api.py` module, with a method per RPC that takes the request message's
262
+ fields as arguments and returns the response message's field(s), i.e. the
263
+ request/response messages are "exploded" for you:
264
+
265
+ ```python
266
+ from protoplasm6 import plasm
267
+ from myshop.orders.orders_api import *
268
+ from myshop.orders.orders_dc import *
269
+
270
+
271
+ class MyOrderService(OrderServiceInterface):
272
+ @plasm.require_params # Raises InvalidArgument if any params are missing
273
+ def get_order(self, order_id: str) -> Order:
274
+ order = my_database.get(order_id)
275
+ if not order:
276
+ raise plasm.NotFound() # Becomes a NOT_FOUND gRPC status code
277
+ return order
278
+
279
+ def list_orders(self, customer_id: str = '') -> list[Order]:
280
+ return my_database.find(customer_id=customer_id)
281
+ ```
282
+
283
+ Unimplemented methods raise `plasm.Unimplemented` (gRPC `UNIMPLEMENTED`).
284
+
285
+ #### Serving
286
+
287
+ The interface knows its gRPC servicer class (from `*_grpc_receiver.py`) so
288
+ you can do:
289
+
290
+ ```python
291
+ from protoplasm6 import plasm
292
+
293
+ server = plasm.GrpcServer('[::]:50051')
294
+ server.add_servicer(OrderServiceInterface.__servicer_cls__(MyOrderService()))
295
+ server.serve()
296
+ ```
297
+
298
+ ...or add the servicer to your own `grpc.server(...)` with
299
+ `servicer.add_to_server(grpc_server)`.
300
+
301
+ #### Calling
302
+
303
+ The `*_grpc_sender.py` module has a client class (named after the service)
304
+ that implements the same interface over gRPC:
305
+
306
+ ```python
307
+ from myshop.orders.orders_grpc_sender import OrderService
308
+
309
+ orders: OrderServiceInterface = OrderService('localhost:50051') # credentials=True for SSL, options={...} for channel options
310
+ # ...or, while it's all still one monolith: orders = MyOrderService()
311
+
312
+ order = orders.get_order('o1')
313
+
314
+ try:
315
+ orders.get_order('nope')
316
+ except plasm.NotFound:
317
+ ... # gRPC status codes are raised as the corresponding plasm errors
318
+ ```
319
+
320
+ #### Call metadata (headers)
321
+
322
+ To send metadata (i.e. headers, e.g. for authorization) with calls, give the
323
+ client a `metadata` dict (or a callable returning one, which gets called for
324
+ every call, e.g. to refresh tokens) and/or use the `call_metadata` context
325
+ manager:
326
+
327
+ ```python
328
+ orders = OrderService('localhost:50051', metadata={'authorization': f'Bearer {token}'})
329
+
330
+ with plasm.call_metadata({'x-request-id': 'r1'}): # For all calls in this block
331
+ orders.get_order('o1')
332
+ ```
333
+
334
+ On the service side, decorate a method with `@plasm.takes_context` to get a
335
+ `context` argument and read headers with `context.get_meta('authorization')`.
336
+
337
+ Streaming RPCs (`stream` in the proto) are supported as well, with
338
+ `plasm.RequestIterator` / `plasm.ResponseIterator` on either side (see
339
+ `tests/servicetestutils` and the `river.proto` tests).
340
+
341
+ #### Decorators
342
+
343
+ - `plasm.require_params` - Raises `InvalidArgument` if required params are
344
+ `None`. Use `@plasm.require_params` for all params, or
345
+ `@plasm.require_params('foo', 'bar')` for specific ones, and
346
+ `typecheck=True` to also check the params against their type annotations
347
+ (both `typing` style annotations like `Optional[List[int]]` and modern ones
348
+ like `list[int] | None` are supported).
349
+ - `plasm.takes_context` - Marks a method as taking the gRPC call context as
350
+ a keyword argument (`context` by default).
351
+
352
+ #### Errors
353
+
354
+ `protoplasm6.plasm` (and `protoplasm6.errors`) has an exception for every gRPC
355
+ status code: `NotFound`, `InvalidArgument`, `Unimplemented`,
356
+ `PermissionDenied`, `Unauthenticated`, `Unavailable`, `DeadlineExceeded`,
357
+ `AlreadyExists`, `FailedPrecondition`, `DataLoss`, `ResourceExhausted`,
358
+ `OutOfRange`, `Internal`, `Cancelled`, `Aborted` and `Unknown`. Raise them in
359
+ your implementation and they're raised again on the client side.
360
+
361
+ ## Migrating from `protoplasm` 5.x
362
+
363
+ 1. Replace the `protoplasm` and `neobuilder` requirements with
364
+ `protoplasm6 >=6.0, <7` and `neobuilder6 >=6.0, <7` (and `ccptools` with
365
+ `batutils`, if you used it).
366
+ 2. Rebuild your protos with Neobuilder6 (the generated code now imports
367
+ `protoplasm6`, and the `*_pb2.py` files need `protobuf` 7).
368
+ 3. Replace `protoplasm` with `protoplasm6` in your own imports, e.g.
369
+ `from protoplasm import plasm` -> `from protoplasm6 import plasm`.
370
+ 4. Make sure you're on Python 3.10 or later.
371
+
372
+ The API itself is unchanged.
373
+
374
+ ## Development
375
+
376
+ The unit tests need Neobuilder6 to build the test protos. With both repos
377
+ checked out side by side:
378
+
379
+ ```
380
+ python -m venv .venv
381
+ .venv/Scripts/activate # or: source .venv/bin/activate
382
+ pip install -r requirements-unittesting.txt
383
+ pip install --no-deps -e . -e ../neobuilder6
384
+ python -m unittest discover -v -f ./tests
385
+ ```
386
+
387
+ ### CI/CD
388
+
389
+ GitHub Actions run the unit tests (on Python 3.10 to 3.14) for pushes to
390
+ `main` and for all pull requests, and publish to PyPI (after running the
391
+ tests again and checking that the release tag, e.g. `v6.1.0` or `6.1.0`,
392
+ matches `__version__`) whenever a GitHub release (or pre-release) is created.
393
+ Publishing needs a `PYPI_UPLOAD_TOKEN` repository secret.
394
+
395
+ Since the tests of each package need the other one, CI installs
396
+ Neobuilder6 (without its dependencies) from the branch with the **same name** in its repo if there is
397
+ one (so changes spanning both repos can be tested together, e.g. with a pull
398
+ request from the same branch name in both) or from `main` otherwise. When
399
+ publishing, Neobuilder6 (without its dependencies) comes from PyPI if a matching release is there
400
+ (otherwise from `main`).
401
+
402
+ When releasing both packages, release Protoplasm6 first, then Neobuilder6.