dst-server 0.2.0__tar.gz → 0.2.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.
- {dst_server-0.2.0 → dst_server-0.2.2}/PKG-INFO +34 -15
- {dst_server-0.2.0 → dst_server-0.2.2}/README.md +33 -14
- {dst_server-0.2.0 → dst_server-0.2.2}/pyproject.toml +1 -1
- {dst_server-0.2.0 → dst_server-0.2.2}/pyproject.toml.orig +1 -1
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/archive.py +27 -4
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/agent.py +7 -1
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/controller.py +4 -2
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/daemon.py +2 -5
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/subscriptions.py +2 -1
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/commands.py +0 -15
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/errors.py +0 -1
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/client.py +35 -26
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/lobby.py +9 -6
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/telemetry.lua +2 -2
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/wire.lua +21 -8
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/snapshot.py +8 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/steamcmd.py +19 -17
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/workshop.py +4 -2
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/netdata.py +2 -1
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/__init__.py +1 -2
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/client.py +18 -13
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/schema/rpc.capnp +2 -2
- dst_server-0.2.2/src/dst_server/rpc/schema.py +12 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/servants.py +16 -31
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/console.py +3 -2
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/lifecycle.py +4 -3
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/server.py +6 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/otel.py +9 -5
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/outbox.py +24 -10
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/recorder.py +58 -25
- dst_server-0.2.0/src/dst_server/rpc/schema.py +0 -43
- {dst_server-0.2.0 → dst_server-0.2.2}/LICENSE +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/cli.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/generator.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/values.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/visitors.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/api.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/cli.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/console.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/service.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/concurrency.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/files.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/ini.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/models.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/overrides.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/presets.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/store.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/world.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/application.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/models.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/quadlet.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/base.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/player.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/record.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/server.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/world.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/client.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/enums.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/rpc.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/enums.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/schema.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/version.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei_id.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/actions.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/commands.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/player_events.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/player_queries.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/player_values.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/state.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/values.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/world_events.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/world_queries.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server.lua +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua_codec.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/base.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/cluster.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/driver.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/entity.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/player.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/server.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/telemetry.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/files.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/native.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/process.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/py.typed +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/codec.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/transport.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/config.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/driver.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/fds.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/operational.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/request.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/supervisor.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/__init__.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/config.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/exporter.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/stream.py +0 -0
- {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/timeouts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dst-server
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Start and control a Don't Starve Together server process.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -383,7 +383,7 @@ The master Agent registers in-process; secondary Agents register through the Pod
|
|
|
383
383
|
|
|
384
384
|
| Channel | Purpose |
|
|
385
385
|
| --- | --- |
|
|
386
|
-
| `/cluster/.dst-server.sock` | Public Cap'n Proto RPC
|
|
386
|
+
| `/cluster/.dst-server.sock` | Public Cap'n Proto RPC. |
|
|
387
387
|
| Game FD 3 | Lua command input. |
|
|
388
388
|
| Game FD 4 | Command text output; raw Lua must explicitly `print`. |
|
|
389
389
|
| Game FD 5 | Native lifecycle events such as Ready, Session, Saved, and Stopping. |
|
|
@@ -582,8 +582,6 @@ Each endpoint accepts only its declared command scope.
|
|
|
582
582
|
Game clients handle game operations; controllers own process lifecycle and cluster coordination.
|
|
583
583
|
|
|
584
584
|
Cap'n Proto carries commands through `call` and observations through subscription capabilities.
|
|
585
|
-
The handshake fingerprint covers the capability schema and Pydantic request, result, event, and error schemas.
|
|
586
|
-
Use matching client and daemon versions.
|
|
587
585
|
Configuration payloads preserve omitted fields, explicit `False`, world override types, and secrets needed for saving.
|
|
588
586
|
|
|
589
587
|
Import cluster results, statuses, and observation cursors from [models.cluster](src/dst_server/models/cluster.py).
|
|
@@ -773,7 +771,9 @@ if catalog.has_more and catalog.snapshots:
|
|
|
773
771
|
The Agent rejects path escapes, symlinks, invalid metadata, and session changes during a query.
|
|
774
772
|
For standalone reads, use `WorldSnapshotMetadata.load(path)` / `PlayerSnapshotMetadata.load(path)` from [models.snapshot](src/dst_server/models/snapshot.py).
|
|
775
773
|
The loaders parse only UTF-8 Lua literals and support native text headers and trailing NULs.
|
|
776
|
-
|
|
774
|
+
Additional fields in `clock` and `seasons` written by Mods are ignored; known fields retain strict validation.
|
|
775
|
+
Unknown fields elsewhere, wrong types, and dynamic expressions are rejected.
|
|
776
|
+
The day uses the standard `clock.cycles + 1`, without interpreting Mod calendars.
|
|
777
777
|
The world model covers `clock`, `seasons`, and nested fields.
|
|
778
778
|
The player model exposes `character`, including Mod character identifiers.
|
|
779
779
|
|
|
@@ -838,22 +838,21 @@ Export detects file changes but cannot guarantee an atomic snapshot of an online
|
|
|
838
838
|
The input must remain unchanged.
|
|
839
839
|
Exports and uploads are available; there is no import API yet.
|
|
840
840
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
```shell
|
|
844
|
-
export AWS_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com'
|
|
845
|
-
export AWS_BUCKET='your-bucket'
|
|
846
|
-
export AWS_ACCESS_KEY_ID='your-access-key-id'
|
|
847
|
-
export AWS_SECRET_ACCESS_KEY='your-secret-access-key'
|
|
848
|
-
```
|
|
841
|
+
Pass S3 connection settings and credentials directly when uploading to R2:
|
|
849
842
|
|
|
850
843
|
```python
|
|
851
844
|
from pathlib import Path
|
|
852
845
|
|
|
846
|
+
from pydantic import SecretStr
|
|
847
|
+
|
|
853
848
|
from dst_server.archive import export_cluster
|
|
854
849
|
|
|
855
850
|
with export_cluster(Path("/srv/dst/000")) as archive:
|
|
856
851
|
result = archive.upload(
|
|
852
|
+
endpoint="https://<account-id>.r2.cloudflarestorage.com",
|
|
853
|
+
bucket="your-bucket",
|
|
854
|
+
access_key_id=SecretStr("your-access-key-id"),
|
|
855
|
+
secret_access_key=SecretStr("your-secret-access-key"),
|
|
857
856
|
object_prefix="rooms/exports/",
|
|
858
857
|
url_prefix="https://downloads.example.com/",
|
|
859
858
|
)
|
|
@@ -862,6 +861,25 @@ print(result.key)
|
|
|
862
861
|
print(result.url)
|
|
863
862
|
```
|
|
864
863
|
|
|
864
|
+
| Upload argument | Type | Default | Environment fallback |
|
|
865
|
+
| --- | --- | --- | --- |
|
|
866
|
+
| `bucket` | `str \| None` | `None` | `AWS_BUCKET` |
|
|
867
|
+
| `endpoint` | `str \| None` | `None` | `AWS_ENDPOINT_URL_S3`, then `AWS_ENDPOINT` |
|
|
868
|
+
| `region` | `str` | `"auto"` | None; the argument overrides `AWS_REGION` |
|
|
869
|
+
| `access_key_id` | `SecretStr \| None` | `None` | `AWS_ACCESS_KEY_ID` |
|
|
870
|
+
| `secret_access_key` | `SecretStr \| None` | `None` | `AWS_SECRET_ACCESS_KEY` |
|
|
871
|
+
| `session_token` | `SecretStr \| None` | `None` | `AWS_SESSION_TOKEN` |
|
|
872
|
+
| `object_prefix` | `str` | `""` | None |
|
|
873
|
+
| `url_prefix` | `str \| None` | `None` | None |
|
|
874
|
+
|
|
875
|
+
The three credential arguments require `SecretStr` instances; plain strings are rejected.
|
|
876
|
+
Secrets are unwrapped only when creating `S3Store` and are never written to the archive.
|
|
877
|
+
Explicit values override the corresponding environment settings, including `AWS_ENDPOINT_URL_S3` for `endpoint`.
|
|
878
|
+
`None` leaves that field to [obstore's environment configuration](https://developmentseed.org/obstore/latest/api/store/aws/#obstore.store.S3Config).
|
|
879
|
+
This fallback applies per field: an omitted `session_token` can still come from the environment when both keys are explicit.
|
|
880
|
+
Other obstore options retain their environment behavior.
|
|
881
|
+
Calling `upload()` without connection or credential arguments continues to use AWS environment variables.
|
|
882
|
+
|
|
865
883
|
`upload()` reads from the start of the stream and returns `ArchiveUploadResult` with `key` and `url` fields.
|
|
866
884
|
The object key is `object_prefix + archive.filename`; `object_prefix` defaults to an empty string.
|
|
867
885
|
The filename remains `DST-<room-id>-<UTC timestamp>.7z`, with the timestamp precise to seconds.
|
|
@@ -871,8 +889,9 @@ Both prefixes are explicit SDK arguments and are concatenated literally.
|
|
|
871
889
|
`object_prefix` must not begin with `/`, which the storage backend would otherwise strip from the key.
|
|
872
890
|
Supply any required separators, such as `/` or `?file=`, yourself.
|
|
873
891
|
Query prefixes and trailing separators are preserved, and the URL is never inferred from the bucket or S3 endpoint.
|
|
874
|
-
|
|
875
|
-
`
|
|
892
|
+
The two prefixes are ordinary strings and have no corresponding environment variables.
|
|
893
|
+
The default `region="auto"` uses [R2's `auto` region](https://developers.cloudflare.com/r2/api/s3/api/#bucket-region).
|
|
894
|
+
Pass `region` explicitly when uploading to a different S3 region.
|
|
876
895
|
[obstore handles multipart uploads](https://developmentseed.org/obstore/latest/api/put/); errors propagate to the caller, and local temporary files are still cleaned up.
|
|
877
896
|
Remote parts from failed uploads may remain; R2 removes them after seven days by default.
|
|
878
897
|
Configure this through [lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/).
|
|
@@ -360,7 +360,7 @@ The master Agent registers in-process; secondary Agents register through the Pod
|
|
|
360
360
|
|
|
361
361
|
| Channel | Purpose |
|
|
362
362
|
| --- | --- |
|
|
363
|
-
| `/cluster/.dst-server.sock` | Public Cap'n Proto RPC
|
|
363
|
+
| `/cluster/.dst-server.sock` | Public Cap'n Proto RPC. |
|
|
364
364
|
| Game FD 3 | Lua command input. |
|
|
365
365
|
| Game FD 4 | Command text output; raw Lua must explicitly `print`. |
|
|
366
366
|
| Game FD 5 | Native lifecycle events such as Ready, Session, Saved, and Stopping. |
|
|
@@ -559,8 +559,6 @@ Each endpoint accepts only its declared command scope.
|
|
|
559
559
|
Game clients handle game operations; controllers own process lifecycle and cluster coordination.
|
|
560
560
|
|
|
561
561
|
Cap'n Proto carries commands through `call` and observations through subscription capabilities.
|
|
562
|
-
The handshake fingerprint covers the capability schema and Pydantic request, result, event, and error schemas.
|
|
563
|
-
Use matching client and daemon versions.
|
|
564
562
|
Configuration payloads preserve omitted fields, explicit `False`, world override types, and secrets needed for saving.
|
|
565
563
|
|
|
566
564
|
Import cluster results, statuses, and observation cursors from [models.cluster](src/dst_server/models/cluster.py).
|
|
@@ -750,7 +748,9 @@ if catalog.has_more and catalog.snapshots:
|
|
|
750
748
|
The Agent rejects path escapes, symlinks, invalid metadata, and session changes during a query.
|
|
751
749
|
For standalone reads, use `WorldSnapshotMetadata.load(path)` / `PlayerSnapshotMetadata.load(path)` from [models.snapshot](src/dst_server/models/snapshot.py).
|
|
752
750
|
The loaders parse only UTF-8 Lua literals and support native text headers and trailing NULs.
|
|
753
|
-
|
|
751
|
+
Additional fields in `clock` and `seasons` written by Mods are ignored; known fields retain strict validation.
|
|
752
|
+
Unknown fields elsewhere, wrong types, and dynamic expressions are rejected.
|
|
753
|
+
The day uses the standard `clock.cycles + 1`, without interpreting Mod calendars.
|
|
754
754
|
The world model covers `clock`, `seasons`, and nested fields.
|
|
755
755
|
The player model exposes `character`, including Mod character identifiers.
|
|
756
756
|
|
|
@@ -815,22 +815,21 @@ Export detects file changes but cannot guarantee an atomic snapshot of an online
|
|
|
815
815
|
The input must remain unchanged.
|
|
816
816
|
Exports and uploads are available; there is no import API yet.
|
|
817
817
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
```shell
|
|
821
|
-
export AWS_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com'
|
|
822
|
-
export AWS_BUCKET='your-bucket'
|
|
823
|
-
export AWS_ACCESS_KEY_ID='your-access-key-id'
|
|
824
|
-
export AWS_SECRET_ACCESS_KEY='your-secret-access-key'
|
|
825
|
-
```
|
|
818
|
+
Pass S3 connection settings and credentials directly when uploading to R2:
|
|
826
819
|
|
|
827
820
|
```python
|
|
828
821
|
from pathlib import Path
|
|
829
822
|
|
|
823
|
+
from pydantic import SecretStr
|
|
824
|
+
|
|
830
825
|
from dst_server.archive import export_cluster
|
|
831
826
|
|
|
832
827
|
with export_cluster(Path("/srv/dst/000")) as archive:
|
|
833
828
|
result = archive.upload(
|
|
829
|
+
endpoint="https://<account-id>.r2.cloudflarestorage.com",
|
|
830
|
+
bucket="your-bucket",
|
|
831
|
+
access_key_id=SecretStr("your-access-key-id"),
|
|
832
|
+
secret_access_key=SecretStr("your-secret-access-key"),
|
|
834
833
|
object_prefix="rooms/exports/",
|
|
835
834
|
url_prefix="https://downloads.example.com/",
|
|
836
835
|
)
|
|
@@ -839,6 +838,25 @@ print(result.key)
|
|
|
839
838
|
print(result.url)
|
|
840
839
|
```
|
|
841
840
|
|
|
841
|
+
| Upload argument | Type | Default | Environment fallback |
|
|
842
|
+
| --- | --- | --- | --- |
|
|
843
|
+
| `bucket` | `str \| None` | `None` | `AWS_BUCKET` |
|
|
844
|
+
| `endpoint` | `str \| None` | `None` | `AWS_ENDPOINT_URL_S3`, then `AWS_ENDPOINT` |
|
|
845
|
+
| `region` | `str` | `"auto"` | None; the argument overrides `AWS_REGION` |
|
|
846
|
+
| `access_key_id` | `SecretStr \| None` | `None` | `AWS_ACCESS_KEY_ID` |
|
|
847
|
+
| `secret_access_key` | `SecretStr \| None` | `None` | `AWS_SECRET_ACCESS_KEY` |
|
|
848
|
+
| `session_token` | `SecretStr \| None` | `None` | `AWS_SESSION_TOKEN` |
|
|
849
|
+
| `object_prefix` | `str` | `""` | None |
|
|
850
|
+
| `url_prefix` | `str \| None` | `None` | None |
|
|
851
|
+
|
|
852
|
+
The three credential arguments require `SecretStr` instances; plain strings are rejected.
|
|
853
|
+
Secrets are unwrapped only when creating `S3Store` and are never written to the archive.
|
|
854
|
+
Explicit values override the corresponding environment settings, including `AWS_ENDPOINT_URL_S3` for `endpoint`.
|
|
855
|
+
`None` leaves that field to [obstore's environment configuration](https://developmentseed.org/obstore/latest/api/store/aws/#obstore.store.S3Config).
|
|
856
|
+
This fallback applies per field: an omitted `session_token` can still come from the environment when both keys are explicit.
|
|
857
|
+
Other obstore options retain their environment behavior.
|
|
858
|
+
Calling `upload()` without connection or credential arguments continues to use AWS environment variables.
|
|
859
|
+
|
|
842
860
|
`upload()` reads from the start of the stream and returns `ArchiveUploadResult` with `key` and `url` fields.
|
|
843
861
|
The object key is `object_prefix + archive.filename`; `object_prefix` defaults to an empty string.
|
|
844
862
|
The filename remains `DST-<room-id>-<UTC timestamp>.7z`, with the timestamp precise to seconds.
|
|
@@ -848,8 +866,9 @@ Both prefixes are explicit SDK arguments and are concatenated literally.
|
|
|
848
866
|
`object_prefix` must not begin with `/`, which the storage backend would otherwise strip from the key.
|
|
849
867
|
Supply any required separators, such as `/` or `?file=`, yourself.
|
|
850
868
|
Query prefixes and trailing separators are preserved, and the URL is never inferred from the bucket or S3 endpoint.
|
|
851
|
-
|
|
852
|
-
`
|
|
869
|
+
The two prefixes are ordinary strings and have no corresponding environment variables.
|
|
870
|
+
The default `region="auto"` uses [R2's `auto` region](https://developers.cloudflare.com/r2/api/s3/api/#bucket-region).
|
|
871
|
+
Pass `region` explicitly when uploading to a different S3 region.
|
|
853
872
|
[obstore handles multipart uploads](https://developmentseed.org/obstore/latest/api/put/); errors propagate to the caller, and local temporary files are still cleaned up.
|
|
854
873
|
Remote parts from failed uploads may remain; R2 removes them after seven days by default.
|
|
855
874
|
Configure this through [lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/).
|
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from tempfile import TemporaryFile
|
|
11
11
|
from typing import Annotated, BinaryIO
|
|
12
12
|
|
|
13
|
-
from pydantic import ConfigDict, Field, validate_call
|
|
13
|
+
from pydantic import ConfigDict, Field, InstanceOf, SecretStr, validate_call
|
|
14
14
|
|
|
15
15
|
from dst_server.klei_id import encode_klei_id
|
|
16
16
|
|
|
@@ -33,17 +33,39 @@ class ClusterArchive:
|
|
|
33
33
|
filename: str
|
|
34
34
|
stream: BinaryIO
|
|
35
35
|
|
|
36
|
-
@validate_call(config=ConfigDict(strict=True))
|
|
36
|
+
@validate_call(config=ConfigDict(strict=True, hide_input_in_errors=True))
|
|
37
37
|
def upload(
|
|
38
38
|
self,
|
|
39
39
|
*,
|
|
40
|
+
bucket: str | None = None,
|
|
41
|
+
endpoint: str | None = None,
|
|
42
|
+
region: str = "auto",
|
|
43
|
+
access_key_id: InstanceOf[SecretStr] | None = None,
|
|
44
|
+
secret_access_key: InstanceOf[SecretStr] | None = None,
|
|
45
|
+
session_token: InstanceOf[SecretStr] | None = None,
|
|
40
46
|
object_prefix: Annotated[str, Field(pattern=r"^(?:[^/]|$)")] = "",
|
|
41
47
|
url_prefix: str | None = None,
|
|
42
48
|
) -> ArchiveUploadResult:
|
|
43
|
-
"""Upload
|
|
49
|
+
"""Upload with explicit S3 settings overriding AWS environment variables."""
|
|
44
50
|
from obstore.store import S3Store
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
settings = {
|
|
53
|
+
"aws_endpoint_url_s3": endpoint,
|
|
54
|
+
"access_key_id": access_key_id,
|
|
55
|
+
"secret_access_key": secret_access_key,
|
|
56
|
+
"session_token": session_token,
|
|
57
|
+
}
|
|
58
|
+
store = S3Store(
|
|
59
|
+
bucket,
|
|
60
|
+
region=region,
|
|
61
|
+
**{
|
|
62
|
+
name: value.get_secret_value()
|
|
63
|
+
if isinstance(value, SecretStr)
|
|
64
|
+
else value
|
|
65
|
+
for name, value in settings.items()
|
|
66
|
+
if value is not None
|
|
67
|
+
},
|
|
68
|
+
)
|
|
47
69
|
key = f"{object_prefix}{self.filename}"
|
|
48
70
|
self.stream.seek(0)
|
|
49
71
|
store.put(
|
|
@@ -154,6 +176,7 @@ def export_cluster( # ruff: ignore[complex-structure, too-many-branches]
|
|
|
154
176
|
if saves != _save_files(directory, configuration, encode_user_path):
|
|
155
177
|
msg = "saves changed during export; use a quiescent copy or stop the games"
|
|
156
178
|
raise RuntimeError(msg)
|
|
179
|
+
del saves, files, exported, configuration
|
|
157
180
|
stream.seek(0)
|
|
158
181
|
yield ClusterArchive(filename, stream)
|
|
159
182
|
|
|
@@ -86,7 +86,7 @@ class ShardAgent:
|
|
|
86
86
|
self._fatal = asyncio.Event()
|
|
87
87
|
self._failure_id: ULID | None = None
|
|
88
88
|
self._started_at_ns: int | None = None
|
|
89
|
-
self.failures: asyncio.Queue[ShardSupervisorStatus] = asyncio.Queue()
|
|
89
|
+
self.failures: asyncio.Queue[ShardSupervisorStatus] = asyncio.Queue(maxsize=1)
|
|
90
90
|
self.supervisor = ShardSupervisor(
|
|
91
91
|
shard.name,
|
|
92
92
|
self._new_server,
|
|
@@ -474,6 +474,9 @@ class ShardAgent:
|
|
|
474
474
|
|
|
475
475
|
async def _failed(self, status: ShardSupervisorStatus) -> None:
|
|
476
476
|
self._failure_id = ULID()
|
|
477
|
+
# Failure reports wake reconciliation, which reads the current status.
|
|
478
|
+
if self.failures.full():
|
|
479
|
+
self.failures.get_nowait()
|
|
477
480
|
self.failures.put_nowait(status)
|
|
478
481
|
|
|
479
482
|
def _background_done(
|
|
@@ -536,6 +539,7 @@ class ShardAgent:
|
|
|
536
539
|
if isinstance(event, SessionEvent):
|
|
537
540
|
self._generation_sequence += 1
|
|
538
541
|
self._event_changed.notify_all()
|
|
542
|
+
del observed, event
|
|
539
543
|
|
|
540
544
|
async def _drain_game_events(self, server: Server) -> None:
|
|
541
545
|
attempt = ULID.from_str(server.game_events.nonce)
|
|
@@ -560,6 +564,7 @@ class ShardAgent:
|
|
|
560
564
|
event=observed.record,
|
|
561
565
|
)
|
|
562
566
|
)
|
|
567
|
+
del observed
|
|
563
568
|
|
|
564
569
|
async def _drain_operational(self, server: Server) -> None:
|
|
565
570
|
while (record := await server.read_operational_event()) is not None:
|
|
@@ -582,3 +587,4 @@ class ShardAgent:
|
|
|
582
587
|
event=record.event_name,
|
|
583
588
|
body=record.body,
|
|
584
589
|
)
|
|
590
|
+
del record
|
|
@@ -1141,8 +1141,9 @@ class ClusterController(ClusterAPI):
|
|
|
1141
1141
|
)
|
|
1142
1142
|
self._reconcile_task.add_done_callback(self._consume_task_result)
|
|
1143
1143
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1144
|
+
def _consume_task_result(self, task: asyncio.Task[None]) -> None:
|
|
1145
|
+
if self._reconcile_task is task:
|
|
1146
|
+
self._reconcile_task = None
|
|
1146
1147
|
if not task.cancelled():
|
|
1147
1148
|
task.exception()
|
|
1148
1149
|
|
|
@@ -1175,6 +1176,7 @@ class ClusterController(ClusterAPI):
|
|
|
1175
1176
|
for item in batch:
|
|
1176
1177
|
for target in targets:
|
|
1177
1178
|
target.publish(item)
|
|
1179
|
+
del batch, item
|
|
1178
1180
|
except SubscriptionOverflowError:
|
|
1179
1181
|
logger.warning(
|
|
1180
1182
|
"internal shard relay overflowed; resubscribing: {shard}",
|
|
@@ -16,7 +16,7 @@ from dst_server.configuration.models import ShardName
|
|
|
16
16
|
from dst_server.configuration.store import ConfigurationStore
|
|
17
17
|
from dst_server.rpc.client import rpc_runtime
|
|
18
18
|
from dst_server.rpc.codec import unwrap_outcome
|
|
19
|
-
from dst_server.rpc.schema import
|
|
19
|
+
from dst_server.rpc.schema import load_schema
|
|
20
20
|
from dst_server.rpc.servants import (
|
|
21
21
|
AgentServant,
|
|
22
22
|
BootstrapServant,
|
|
@@ -355,10 +355,7 @@ async def _registered_cycle(agent: ShardAgent, internal_address: str) -> None:
|
|
|
355
355
|
stack.callback(client.close)
|
|
356
356
|
registry = client.bootstrap().cast_as(load_schema().WorkerRegistry)
|
|
357
357
|
async with asyncio.timeout(DEFAULT_LIFECYCLE_TIMEOUT):
|
|
358
|
-
response = await registry.register(
|
|
359
|
-
schemaFingerprint=SCHEMA_FINGERPRINT,
|
|
360
|
-
agent=servant,
|
|
361
|
-
)
|
|
358
|
+
response = await registry.register(agent=servant)
|
|
362
359
|
unwrap_outcome(response.result)
|
|
363
360
|
disconnected = asyncio.ensure_future(client.on_disconnect())
|
|
364
361
|
while True:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from typing import Annotated
|
|
3
|
+
from weakref import WeakSet
|
|
3
4
|
|
|
4
5
|
from pydantic import Field, TypeAdapter
|
|
5
6
|
|
|
@@ -11,7 +12,7 @@ BATCH_SIZE = TypeAdapter(Annotated[int, Field(ge=1, le=MAX_BATCH_SIZE)])
|
|
|
11
12
|
|
|
12
13
|
class Broadcast[T]:
|
|
13
14
|
def __init__(self) -> None:
|
|
14
|
-
self._subscriptions:
|
|
15
|
+
self._subscriptions: WeakSet[Subscription[T]] = WeakSet()
|
|
15
16
|
self._closed = False
|
|
16
17
|
|
|
17
18
|
def subscribe(self) -> Subscription[T]:
|
|
@@ -492,18 +492,3 @@ def parse_request(payload: bytes, *, scope: Scope) -> Request[Any]:
|
|
|
492
492
|
if envelope.timeout is not None:
|
|
493
493
|
arguments |= {"timeout": envelope.timeout}
|
|
494
494
|
return spec.request.model_validate_json(json.dumps(arguments, allow_nan=False))
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def schema_contract() -> dict[str, dict[str, dict[str, Any]]]:
|
|
498
|
-
return {
|
|
499
|
-
scope: {
|
|
500
|
-
method: {
|
|
501
|
-
"request": spec.request.model_json_schema(),
|
|
502
|
-
"result": spec.response.json_schema(),
|
|
503
|
-
"mutation": spec.mutation,
|
|
504
|
-
}
|
|
505
|
-
for (entry_scope, method), spec in sorted(_OPERATIONS.items())
|
|
506
|
-
if entry_scope == scope
|
|
507
|
-
}
|
|
508
|
-
for scope in _ALL
|
|
509
|
-
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
from asyncio import
|
|
2
|
-
from collections.abc import Iterable
|
|
1
|
+
from asyncio import TaskGroup
|
|
2
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
3
3
|
from itertools import chain, product
|
|
4
4
|
from types import TracebackType
|
|
5
|
-
from typing import Self
|
|
5
|
+
from typing import Self, cast
|
|
6
6
|
|
|
7
7
|
import httpx2
|
|
8
8
|
from logbook import Logger
|
|
@@ -111,18 +111,12 @@ class KleiClient:
|
|
|
111
111
|
regions: Iterable[Region | str] = Region,
|
|
112
112
|
platforms: Iterable[Platform] = Platform,
|
|
113
113
|
) -> tuple[Lobby, ...]:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
async with TaskGroup() as group:
|
|
121
|
-
tasks = [
|
|
122
|
-
group.create_task(load(region, platform))
|
|
123
|
-
for region, platform in product(regions, platforms)
|
|
124
|
-
]
|
|
125
|
-
return tuple(chain.from_iterable(task.result() for task in tasks))
|
|
114
|
+
batches = await _map(
|
|
115
|
+
product(regions, platforms),
|
|
116
|
+
lambda pair: self.lobby(*pair),
|
|
117
|
+
self.lobby_concurrency,
|
|
118
|
+
)
|
|
119
|
+
return tuple(chain.from_iterable(batches))
|
|
126
120
|
|
|
127
121
|
async def get_rooms(
|
|
128
122
|
self,
|
|
@@ -132,17 +126,13 @@ class KleiClient:
|
|
|
132
126
|
msg = "a Klei access token is required to query room details"
|
|
133
127
|
raise ValueError(msg)
|
|
134
128
|
if rooms is None:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
async with TaskGroup() as group:
|
|
144
|
-
tasks = [group.create_task(load(*room)) for room in rooms]
|
|
145
|
-
return tuple(room for task in tasks if (room := task.result()) is not None)
|
|
129
|
+
rooms = tuple(
|
|
130
|
+
(lobby.row_id, lobby.region) for lobby in await self.get_lobbies()
|
|
131
|
+
)
|
|
132
|
+
results = await _map(
|
|
133
|
+
rooms, lambda pair: self.room(*pair), self.room_concurrency
|
|
134
|
+
)
|
|
135
|
+
return tuple(room for room in results if room is not None)
|
|
146
136
|
|
|
147
137
|
async def lobby(
|
|
148
138
|
self,
|
|
@@ -203,6 +193,25 @@ class KleiClient:
|
|
|
203
193
|
return data.rows[0] if data.rows else None
|
|
204
194
|
|
|
205
195
|
|
|
196
|
+
async def _map[ItemT, ResultT](
|
|
197
|
+
items: Iterable[ItemT],
|
|
198
|
+
load: Callable[[ItemT], Awaitable[ResultT]],
|
|
199
|
+
concurrency: int,
|
|
200
|
+
) -> tuple[ResultT, ...]:
|
|
201
|
+
pending = enumerate(items)
|
|
202
|
+
results: list[ResultT | None] = []
|
|
203
|
+
|
|
204
|
+
async def worker() -> None:
|
|
205
|
+
for index, item in pending:
|
|
206
|
+
results.append(None)
|
|
207
|
+
results[index] = await load(item)
|
|
208
|
+
|
|
209
|
+
async with TaskGroup() as group:
|
|
210
|
+
for _ in range(concurrency):
|
|
211
|
+
group.create_task(worker())
|
|
212
|
+
return cast("tuple[ResultT, ...]", tuple(results))
|
|
213
|
+
|
|
214
|
+
|
|
206
215
|
def positive(name: str, value: int) -> int:
|
|
207
216
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
208
217
|
msg = f"{name} must be a positive integer"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from collections.abc import Mapping
|
|
2
2
|
from ipaddress import IPv4Address
|
|
3
|
+
from re import sub
|
|
3
4
|
from typing import Annotated
|
|
4
5
|
|
|
5
6
|
from pydantic import (
|
|
@@ -33,12 +34,12 @@ class DataResponse[DataT](KleiModel):
|
|
|
33
34
|
|
|
34
35
|
class Player(KleiModel):
|
|
35
36
|
name: str
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
netid: str
|
|
38
|
+
prefab: Role | str
|
|
39
|
+
colour: str
|
|
40
|
+
eventlevel: int
|
|
40
41
|
|
|
41
|
-
@field_validator("
|
|
42
|
+
@field_validator("prefab", mode="before")
|
|
42
43
|
@classmethod
|
|
43
44
|
def preserve_custom_role(cls, value: object) -> object:
|
|
44
45
|
return Role(value) if isinstance(value, str) and value in Role else value
|
|
@@ -118,7 +119,9 @@ class Room(Lobby):
|
|
|
118
119
|
return ()
|
|
119
120
|
if not isinstance(value, str):
|
|
120
121
|
return value
|
|
121
|
-
players = parse_literal(
|
|
122
|
+
players = parse_literal(
|
|
123
|
+
sub(r"\A\s*return\b", "", value, count=1), "Klei room players"
|
|
124
|
+
)
|
|
122
125
|
if players == {}:
|
|
123
126
|
return ()
|
|
124
127
|
if not isinstance(players, list):
|
|
@@ -30,7 +30,7 @@ local function write(event_name, data)
|
|
|
30
30
|
monotonic_ms = math.floor(GetTimeReal()),
|
|
31
31
|
cycle = cycle == nil and json.null or cycle,
|
|
32
32
|
data = data,
|
|
33
|
-
})
|
|
33
|
+
}, state.max_line_bytes - #state.prefix)
|
|
34
34
|
if type(encoded) ~= "string" then
|
|
35
35
|
error("encoding_failed", 0)
|
|
36
36
|
end
|
|
@@ -65,7 +65,7 @@ end
|
|
|
65
65
|
function telemetry.emit(event_name, data)
|
|
66
66
|
local ok, emitted, failure = pcall(write, event_name, data)
|
|
67
67
|
if not ok then
|
|
68
|
-
telemetry.report(event_name, "encoding_failed")
|
|
68
|
+
telemetry.report(event_name, emitted == "response_too_large" and "event_too_large" or "encoding_failed")
|
|
69
69
|
elseif not emitted then
|
|
70
70
|
telemetry.report(event_name, failure)
|
|
71
71
|
end
|
|
@@ -3,6 +3,7 @@ local wire = {}
|
|
|
3
3
|
local object_marker = {}
|
|
4
4
|
local indeterminate = {}
|
|
5
5
|
local prefix = "DST_SERVER_RESULT|"
|
|
6
|
+
local success_prefix = '{"ok":true,"data":'
|
|
6
7
|
local maximum_line_bytes = 64 * 1024
|
|
7
8
|
local escapes = { ['"'] = '\\"', ["\\"] = "\\\\" }
|
|
8
9
|
|
|
@@ -23,7 +24,9 @@ function wire.indeterminate()
|
|
|
23
24
|
error(indeterminate, 0)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
local function quote(value)
|
|
27
|
+
local function quote(value, remaining)
|
|
28
|
+
local size = #value + 2
|
|
29
|
+
if size > remaining then error("response_too_large", 0) end
|
|
27
30
|
local index = 1
|
|
28
31
|
while true do
|
|
29
32
|
index = string.find(value, "[\128-\255]", index)
|
|
@@ -59,13 +62,21 @@ local function quote(value)
|
|
|
59
62
|
end
|
|
60
63
|
index = index + width + 1
|
|
61
64
|
end
|
|
62
|
-
return '"' .. string.gsub(value, '[%z\1-\31\\"]',
|
|
65
|
+
return '"' .. string.gsub(value, '[%z\1-\31\\"]', function(character)
|
|
66
|
+
local escaped = escapes[character]
|
|
67
|
+
size = size + #escaped - 1
|
|
68
|
+
if size > remaining then error("response_too_large", 0) end
|
|
69
|
+
return escaped
|
|
70
|
+
end) .. '"'
|
|
63
71
|
end
|
|
64
72
|
|
|
65
|
-
function wire.encode(value)
|
|
73
|
+
function wire.encode(value, limit)
|
|
66
74
|
local parts, seen = {}, {}
|
|
75
|
+
local remaining = limit or maximum_line_bytes
|
|
67
76
|
|
|
68
77
|
local function append(text)
|
|
78
|
+
remaining = remaining - #text
|
|
79
|
+
if remaining < 0 then error("response_too_large", 0) end
|
|
69
80
|
parts[#parts + 1] = text
|
|
70
81
|
end
|
|
71
82
|
|
|
@@ -76,7 +87,7 @@ function wire.encode(value)
|
|
|
76
87
|
end
|
|
77
88
|
local kind = type(item)
|
|
78
89
|
if kind == "string" then
|
|
79
|
-
append(quote(item))
|
|
90
|
+
append(quote(item, remaining))
|
|
80
91
|
return
|
|
81
92
|
elseif kind == "boolean" then
|
|
82
93
|
append(item and "true" or "false")
|
|
@@ -127,7 +138,7 @@ function wire.encode(value)
|
|
|
127
138
|
append(",")
|
|
128
139
|
end
|
|
129
140
|
first = false
|
|
130
|
-
append(quote(key))
|
|
141
|
+
append(quote(key, remaining))
|
|
131
142
|
append(":")
|
|
132
143
|
encode(child)
|
|
133
144
|
end
|
|
@@ -153,14 +164,15 @@ function wire.reply(callback)
|
|
|
153
164
|
local ok, data = pcall(callback)
|
|
154
165
|
local payload, failure
|
|
155
166
|
if ok then
|
|
156
|
-
ok, data = pcall(wire.encode, data)
|
|
167
|
+
ok, data = pcall(wire.encode, data, maximum_line_bytes - #prefix - #success_prefix - 1)
|
|
157
168
|
if ok then
|
|
158
|
-
payload =
|
|
169
|
+
payload = success_prefix .. data .. "}"
|
|
159
170
|
if #prefix + #payload > maximum_line_bytes then
|
|
160
171
|
failure = "response_too_large"
|
|
161
172
|
end
|
|
162
173
|
else
|
|
163
|
-
failure = data == "invalid_utf8"
|
|
174
|
+
failure = (data == "invalid_utf8" or data == "response_too_large")
|
|
175
|
+
and data or "invalid_json_value"
|
|
164
176
|
end
|
|
165
177
|
else
|
|
166
178
|
failure = data == indeterminate and "indeterminate" or "lua_error"
|
|
@@ -168,6 +180,7 @@ function wire.reply(callback)
|
|
|
168
180
|
if failure ~= nil then
|
|
169
181
|
payload = '{"ok":false,"error":"' .. failure .. '"}'
|
|
170
182
|
end
|
|
183
|
+
data = nil
|
|
171
184
|
print(prefix .. payload)
|
|
172
185
|
end
|
|
173
186
|
|
|
@@ -78,6 +78,14 @@ class _Metadata(FrozenModel):
|
|
|
78
78
|
msg = f"unsupported snapshot metadata format: {path}"
|
|
79
79
|
raise ValueError(msg)
|
|
80
80
|
value = parse_literal(source.removeprefix("return"), "snapshot metadata")
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
for name, model in (("clock", SnapshotClock), ("seasons", SnapshotSeasons)):
|
|
83
|
+
if isinstance(fields := value.get(name), dict):
|
|
84
|
+
value[name] = {
|
|
85
|
+
key: item
|
|
86
|
+
for key, item in fields.items()
|
|
87
|
+
if key in model.model_fields
|
|
88
|
+
}
|
|
81
89
|
return cls.model_validate(value)
|
|
82
90
|
|
|
83
91
|
|