spatialdds 0.0.1__tar.gz → 0.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.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: spatialdds
3
+ Version: 0.1.0
4
+ Summary: SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N).
5
+ Author: Open AR Cloud
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/OpenArCloud/spatialdds-web
8
+ Project-URL: Specification, https://github.com/OpenArCloud/SpatialDDS-spec
9
+ Keywords: spatialdds,spatial,geopose,anchors,openarcloud
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: GIS
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests>=2.25
19
+ Requires-Dist: websockets>=12
20
+
21
+ # spatialdds
22
+
23
+ SpatialDDS over plain HTTPS and a WebSocket, for programs.
24
+
25
+ ```sh
26
+ pip install spatialdds
27
+ ```
28
+
29
+ `fetch` and a WebSocket really are the whole client — the endpoint's own README
30
+ shows that in three lines of `curl`, and nothing here replaces it. This package
31
+ is the step after that, when a program wants native values instead of canonical
32
+ JSON, bytes it can trust, and refusals it can act on.
33
+
34
+ **Implements Appendix N as of SpatialDDS-spec `9c3e1f2`.** Package versions and
35
+ specification versions move independently; `spatialdds.SPEC_COMMIT` is the one
36
+ that says what the wire looks like.
37
+
38
+ ## What it is for: the lift
39
+
40
+ An endpoint serves Appendix N.1 canonical JSON, and three rules separate that
41
+ from the values a program wants. All three are easy to get subtly wrong:
42
+
43
+ * `int64` and `uint64` are **unconditional decimal strings**. A client that
44
+ treats `stamp.sec` as a string gets surprising comparisons; one that calls
45
+ `int()` on every string corrupts identifiers.
46
+ * An octet run is **base64**.
47
+ * A union is tagged on `"type"`, and carries `_discriminator` once lifted.
48
+
49
+ These are not this library's rules — they are the reference gateway's, and the
50
+ repository gates that the two agree by lifting every record in both committed
51
+ recordings with each and comparing. Same rules, same results, different source
52
+ of the type information: the gateway reads the IDL a recording carries, and a
53
+ client has no recording, so this reads the generated serializer tables that
54
+ travel with the package.
55
+
56
+ A lifted union keeps `_discriminator` rather than `"type"`, because that is
57
+ what the lift produces and inventing a fourth shape would be a fourth rule.
58
+ Pass `lifted=False` to `connect()` to get the canonical JSON untouched.
59
+
60
+ ## The three bars
61
+
62
+ Each one runs on its own, pasted into a fresh shell with nothing else run
63
+ first, which is why the serve line all three need is printed here rather than
64
+ assumed. `gates/library.py` executes each bar against its own fresh endpoint
65
+ and compares its output to what is printed below it.
66
+
67
+ ```sh
68
+ python3 tools/run_gateway.py --mcap samples/fountain-anchors.mcap \
69
+ --manifests samples/manifests --assets samples/assets \
70
+ --publish --token not-a-secret --public-url https://demo.spatialdds.org
71
+ ```
72
+
73
+ **Hello spatial, six lines.** Discover, subscribe, and read typed samples until
74
+ the latched set is complete.
75
+
76
+ <!-- bar: read -->
77
+ ```python
78
+ import spatialdds as sd
79
+ ep = sd.connect("http://127.0.0.1:8787")
80
+ svc = ep.search(geohash="9v6krc")[0]
81
+ for ev in svc.topic("geo_anchor").subscribe(timeout=10):
82
+ if isinstance(ev, sd.EndOfReplay): print("latched set complete:", ev.replayed); break
83
+ print(" ", ev.key, ev.data["method"], ev.data["geopose"]["stamp"]["sec"])
84
+ ```
85
+
86
+ ```console
87
+ anchor/fountain-north-rim Surveyed 1791559800
88
+ anchor/fountain-south-rim VisualFix 1791559800
89
+ anchor/fountain-east-rim VisualFix 1791559800
90
+ anchor/fountain-west-rim VisualFix 1791559800
91
+ anchor/fountain-plaque GNSS 1791559800
92
+ latched set complete: 5
93
+ ```
94
+
95
+ `geopose.stamp.sec` arrives as an `int` there, not as `"1791559800"`. That is
96
+ the whole lift, in the one place a reader will notice it.
97
+
98
+ **Publish the duck, four lines.** The content manifest, and the announce that
99
+ places it.
100
+
101
+ <!-- bar: publish -->
102
+ ```python
103
+ import json, spatialdds as sd
104
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret")
105
+ d = json.load(open("samples/duck.input.json"))
106
+ print("manifest", ep.publish_manifest(d["manifest"]).created, "| announce", ep.publish(topic=d["announce"]["path"], key=d["announce"]["key"], record=d["announce"]["record"]).created)
107
+ ```
108
+
109
+ ```console
110
+ manifest True | announce True
111
+ ```
112
+
113
+ **Resolve and verify, two lines.** The URI resolved per the endpoint's own
114
+ §7.5.2 metadata, and the asset's bytes checked against the hash the manifest
115
+ declared.
116
+
117
+ <!-- bar: resolve -->
118
+ ```python
119
+ import json, spatialdds as sd
120
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret"); ep.publish_manifest(json.load(open("samples/duck.input.json"))["manifest"]); print(len(ep.resolve("spatialdds://demo.spatialdds.org/south-mall/content/duck").fetch_asset()), "bytes verified")
121
+ ```
122
+
123
+ ```console
124
+ 1432 bytes verified
125
+ ```
126
+
127
+ The duck's manifest is published on the first line because these two lines
128
+ have to stand alone. Against an endpoint that already holds the manifest, that
129
+ call goes away and the bar is one line.
130
+
131
+ ## The surface
132
+
133
+ | | |
134
+ | --- | --- |
135
+ | `sd.connect(base, token=None, verify_integrity=True, timeout=15.0, lifted=True)` | an `Endpoint`; nothing is fetched until you ask |
136
+ | `ep.search(geohash=None, kinds=None)` | §3.3.0 search → a list of `Hit` |
137
+ | `ep.near(lat, lon, radius_m)` | a helper over `search`, never a second protocol |
138
+ | `ep.resolve(uri)` | §7.5.2 resolution → `Resolved` |
139
+ | `ep.publish(topic=, key=, record=, if_match=None)` | PUT one instance → `Written` |
140
+ | `ep.publish_manifest(record)` | PUT a manifest, keyed by its own `id` |
141
+ | `ep.withdraw(topic=, key=)` | DELETE: leaves the latched set and disposes |
142
+ | `hit.service_id`, `.kind`, `.coverage`, `.topics` | the typed service manifest |
143
+ | `hit.topic(type_name)` | a `Topic`, URL taken from the manifest, never built |
144
+ | `hit.reaches(lat, lon, radius_m)` | does the declared coverage reach a point |
145
+ | `topic.type`, `.url`, `.name`, `.durability`, `.access`, `.fqn` | what the manifest said |
146
+ | `topic.latched()` | every live instance, lifted |
147
+ | `topic.instance(key)` | one instance, key percent-encoded per N.3 |
148
+ | `topic.subscribe(timeout=None)` | a generator of `Sample` / `Dispose` / `EndOfReplay` / `Liveliness` |
149
+ | `resolved.doc`, `.uri`, `.etag`, `.assets` | the resolved manifest |
150
+ | `resolved.asset_url(i)`, `.fetch_asset(i)` | the URL it would fetch, and the verified bytes |
151
+ | `written.doc`, `.etag`, `.created` | what a publish returned |
152
+ | `sd.lift(fqn, doc)` | the lift on its own, for JSON you already have |
153
+ | `sd.SPEC_COMMIT`, `sd.APPENDIX_N`, `sd.__version__` | what this speaks |
154
+
155
+ `ep.near()` picks a geohash precision whose cell covers the radius, encodes the
156
+ centre and eight points around the query circle so the covering cells are found
157
+ without neighbour arithmetic, searches each distinct cell, and filters the hits
158
+ by their own declared coverage. Everything it sends is an ordinary §3.3.0
159
+ geohash query.
160
+
161
+ ## Errors
162
+
163
+ One family, built from the endpoint's own N.8 problem documents — `status`,
164
+ `type`, `detail` carried, not re-invented. Everything below derives from
165
+ `SpatialDDSError`, so one `except` catches the lot.
166
+
167
+ | | |
168
+ | --- | --- |
169
+ | `Unreachable` | the endpoint did not answer; names the URL and the serve line |
170
+ | `Unauthorized` | 401; says to pass `token=` and where the token is printed |
171
+ | `SurfaceOff` | 405 from a write verb; says to start with `--publish` |
172
+ | `Problem` | any other refusal, with `.status`, `.type`, `.slug`, `.detail` |
173
+ | `IntegrityError` | fetched bytes do not match the declared hash |
174
+ | `UnverifiableIntegrity` | the declared value is not a hash this library can check |
175
+
176
+ That last one is the unusual one and it is deliberate. SpatialDDS counts "a
177
+ checksum field in the body" as a §7.5.2 integrity signal, but nothing specifies
178
+ what such a field digests or in what form (spatialdds-web finding 31). Where a
179
+ declared value does not parse as a SHA-256 of the asset's bytes, this library
180
+ raises rather than reporting a check it did not perform.
181
+
182
+ ## Integrity by default
183
+
184
+ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
185
+ the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
186
+ site, nowhere else.
187
+
188
+ ## Not in 0.1.0
189
+
190
+ Stated so nobody waits for them: no CLI, no async API, no caching beyond ETag
191
+ revalidation, no retry policy, no MCAP tooling, and no DDS dependency of any
192
+ kind. The wire is HTTP and a WebSocket; two dependencies, `requests` and
193
+ `websockets`, and nothing else.
@@ -0,0 +1,173 @@
1
+ # spatialdds
2
+
3
+ SpatialDDS over plain HTTPS and a WebSocket, for programs.
4
+
5
+ ```sh
6
+ pip install spatialdds
7
+ ```
8
+
9
+ `fetch` and a WebSocket really are the whole client — the endpoint's own README
10
+ shows that in three lines of `curl`, and nothing here replaces it. This package
11
+ is the step after that, when a program wants native values instead of canonical
12
+ JSON, bytes it can trust, and refusals it can act on.
13
+
14
+ **Implements Appendix N as of SpatialDDS-spec `9c3e1f2`.** Package versions and
15
+ specification versions move independently; `spatialdds.SPEC_COMMIT` is the one
16
+ that says what the wire looks like.
17
+
18
+ ## What it is for: the lift
19
+
20
+ An endpoint serves Appendix N.1 canonical JSON, and three rules separate that
21
+ from the values a program wants. All three are easy to get subtly wrong:
22
+
23
+ * `int64` and `uint64` are **unconditional decimal strings**. A client that
24
+ treats `stamp.sec` as a string gets surprising comparisons; one that calls
25
+ `int()` on every string corrupts identifiers.
26
+ * An octet run is **base64**.
27
+ * A union is tagged on `"type"`, and carries `_discriminator` once lifted.
28
+
29
+ These are not this library's rules — they are the reference gateway's, and the
30
+ repository gates that the two agree by lifting every record in both committed
31
+ recordings with each and comparing. Same rules, same results, different source
32
+ of the type information: the gateway reads the IDL a recording carries, and a
33
+ client has no recording, so this reads the generated serializer tables that
34
+ travel with the package.
35
+
36
+ A lifted union keeps `_discriminator` rather than `"type"`, because that is
37
+ what the lift produces and inventing a fourth shape would be a fourth rule.
38
+ Pass `lifted=False` to `connect()` to get the canonical JSON untouched.
39
+
40
+ ## The three bars
41
+
42
+ Each one runs on its own, pasted into a fresh shell with nothing else run
43
+ first, which is why the serve line all three need is printed here rather than
44
+ assumed. `gates/library.py` executes each bar against its own fresh endpoint
45
+ and compares its output to what is printed below it.
46
+
47
+ ```sh
48
+ python3 tools/run_gateway.py --mcap samples/fountain-anchors.mcap \
49
+ --manifests samples/manifests --assets samples/assets \
50
+ --publish --token not-a-secret --public-url https://demo.spatialdds.org
51
+ ```
52
+
53
+ **Hello spatial, six lines.** Discover, subscribe, and read typed samples until
54
+ the latched set is complete.
55
+
56
+ <!-- bar: read -->
57
+ ```python
58
+ import spatialdds as sd
59
+ ep = sd.connect("http://127.0.0.1:8787")
60
+ svc = ep.search(geohash="9v6krc")[0]
61
+ for ev in svc.topic("geo_anchor").subscribe(timeout=10):
62
+ if isinstance(ev, sd.EndOfReplay): print("latched set complete:", ev.replayed); break
63
+ print(" ", ev.key, ev.data["method"], ev.data["geopose"]["stamp"]["sec"])
64
+ ```
65
+
66
+ ```console
67
+ anchor/fountain-north-rim Surveyed 1791559800
68
+ anchor/fountain-south-rim VisualFix 1791559800
69
+ anchor/fountain-east-rim VisualFix 1791559800
70
+ anchor/fountain-west-rim VisualFix 1791559800
71
+ anchor/fountain-plaque GNSS 1791559800
72
+ latched set complete: 5
73
+ ```
74
+
75
+ `geopose.stamp.sec` arrives as an `int` there, not as `"1791559800"`. That is
76
+ the whole lift, in the one place a reader will notice it.
77
+
78
+ **Publish the duck, four lines.** The content manifest, and the announce that
79
+ places it.
80
+
81
+ <!-- bar: publish -->
82
+ ```python
83
+ import json, spatialdds as sd
84
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret")
85
+ d = json.load(open("samples/duck.input.json"))
86
+ print("manifest", ep.publish_manifest(d["manifest"]).created, "| announce", ep.publish(topic=d["announce"]["path"], key=d["announce"]["key"], record=d["announce"]["record"]).created)
87
+ ```
88
+
89
+ ```console
90
+ manifest True | announce True
91
+ ```
92
+
93
+ **Resolve and verify, two lines.** The URI resolved per the endpoint's own
94
+ §7.5.2 metadata, and the asset's bytes checked against the hash the manifest
95
+ declared.
96
+
97
+ <!-- bar: resolve -->
98
+ ```python
99
+ import json, spatialdds as sd
100
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret"); ep.publish_manifest(json.load(open("samples/duck.input.json"))["manifest"]); print(len(ep.resolve("spatialdds://demo.spatialdds.org/south-mall/content/duck").fetch_asset()), "bytes verified")
101
+ ```
102
+
103
+ ```console
104
+ 1432 bytes verified
105
+ ```
106
+
107
+ The duck's manifest is published on the first line because these two lines
108
+ have to stand alone. Against an endpoint that already holds the manifest, that
109
+ call goes away and the bar is one line.
110
+
111
+ ## The surface
112
+
113
+ | | |
114
+ | --- | --- |
115
+ | `sd.connect(base, token=None, verify_integrity=True, timeout=15.0, lifted=True)` | an `Endpoint`; nothing is fetched until you ask |
116
+ | `ep.search(geohash=None, kinds=None)` | §3.3.0 search → a list of `Hit` |
117
+ | `ep.near(lat, lon, radius_m)` | a helper over `search`, never a second protocol |
118
+ | `ep.resolve(uri)` | §7.5.2 resolution → `Resolved` |
119
+ | `ep.publish(topic=, key=, record=, if_match=None)` | PUT one instance → `Written` |
120
+ | `ep.publish_manifest(record)` | PUT a manifest, keyed by its own `id` |
121
+ | `ep.withdraw(topic=, key=)` | DELETE: leaves the latched set and disposes |
122
+ | `hit.service_id`, `.kind`, `.coverage`, `.topics` | the typed service manifest |
123
+ | `hit.topic(type_name)` | a `Topic`, URL taken from the manifest, never built |
124
+ | `hit.reaches(lat, lon, radius_m)` | does the declared coverage reach a point |
125
+ | `topic.type`, `.url`, `.name`, `.durability`, `.access`, `.fqn` | what the manifest said |
126
+ | `topic.latched()` | every live instance, lifted |
127
+ | `topic.instance(key)` | one instance, key percent-encoded per N.3 |
128
+ | `topic.subscribe(timeout=None)` | a generator of `Sample` / `Dispose` / `EndOfReplay` / `Liveliness` |
129
+ | `resolved.doc`, `.uri`, `.etag`, `.assets` | the resolved manifest |
130
+ | `resolved.asset_url(i)`, `.fetch_asset(i)` | the URL it would fetch, and the verified bytes |
131
+ | `written.doc`, `.etag`, `.created` | what a publish returned |
132
+ | `sd.lift(fqn, doc)` | the lift on its own, for JSON you already have |
133
+ | `sd.SPEC_COMMIT`, `sd.APPENDIX_N`, `sd.__version__` | what this speaks |
134
+
135
+ `ep.near()` picks a geohash precision whose cell covers the radius, encodes the
136
+ centre and eight points around the query circle so the covering cells are found
137
+ without neighbour arithmetic, searches each distinct cell, and filters the hits
138
+ by their own declared coverage. Everything it sends is an ordinary §3.3.0
139
+ geohash query.
140
+
141
+ ## Errors
142
+
143
+ One family, built from the endpoint's own N.8 problem documents — `status`,
144
+ `type`, `detail` carried, not re-invented. Everything below derives from
145
+ `SpatialDDSError`, so one `except` catches the lot.
146
+
147
+ | | |
148
+ | --- | --- |
149
+ | `Unreachable` | the endpoint did not answer; names the URL and the serve line |
150
+ | `Unauthorized` | 401; says to pass `token=` and where the token is printed |
151
+ | `SurfaceOff` | 405 from a write verb; says to start with `--publish` |
152
+ | `Problem` | any other refusal, with `.status`, `.type`, `.slug`, `.detail` |
153
+ | `IntegrityError` | fetched bytes do not match the declared hash |
154
+ | `UnverifiableIntegrity` | the declared value is not a hash this library can check |
155
+
156
+ That last one is the unusual one and it is deliberate. SpatialDDS counts "a
157
+ checksum field in the body" as a §7.5.2 integrity signal, but nothing specifies
158
+ what such a field digests or in what form (spatialdds-web finding 31). Where a
159
+ declared value does not parse as a SHA-256 of the asset's bytes, this library
160
+ raises rather than reporting a check it did not perform.
161
+
162
+ ## Integrity by default
163
+
164
+ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
165
+ the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
166
+ site, nowhere else.
167
+
168
+ ## Not in 0.1.0
169
+
170
+ Stated so nobody waits for them: no CLI, no async API, no caching beyond ETag
171
+ revalidation, no retry policy, no MCAP tooling, and no DDS dependency of any
172
+ kind. The wire is HTTP and a WebSocket; two dependencies, `requests` and
173
+ `websockets`, and nothing else.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spatialdds"
7
+ version = "0.1.0"
8
+ description = "SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Open AR Cloud" }]
13
+ keywords = ["spatialdds", "spatial", "geopose", "anchors", "openarcloud"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Scientific/Engineering :: GIS",
21
+ ]
22
+ # Two, and no more. The wire is HTTP and a WebSocket; anything else would be
23
+ # this library's opinion rather than the binding's requirement.
24
+ dependencies = ["requests>=2.25", "websockets>=12"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/OpenArCloud/spatialdds-web"
28
+ Specification = "https://github.com/OpenArCloud/SpatialDDS-spec"
29
+
30
+ [tool.setuptools]
31
+ packages = ["spatialdds"]
32
+
33
+ [tool.setuptools.package-data]
34
+ # The generated serializer tables travel with the package: they are what the
35
+ # lift reads, and a client has no recording to learn the types from.
36
+ spatialdds = ["serializer_tables.json"]