ajar-connector 0.5.3__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,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: ajar-connector
3
+ Version: 0.5.3
4
+ Summary: Python SDK for building byte-compatible connectors to the Ajar integration plane.
5
+ Author: Ajar connectors contributors
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/promaka/ajar-connectors
8
+ Project-URL: Documentation, https://github.com/promaka/ajar-connectors/blob/main/docs/embedding-python.md
9
+ Project-URL: Wire contract, https://github.com/promaka/ajar-connectors/blob/main/docs/wire-contract-v1.md
10
+ Project-URL: Source, https://github.com/promaka/ajar-connectors
11
+ Project-URL: Changelog, https://github.com/promaka/ajar-connectors/blob/main/CHANGELOG.md
12
+ Keywords: ajar,connector,defence,interoperability,protobuf,ed25519
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Networking
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: protobuf<8,>=7.35
21
+ Requires-Dist: cryptography>=42
22
+ Provides-Extra: examples
23
+ Requires-Dist: nats-py>=2.6; extra == "examples"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+
27
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
28
+ # ajar-connector (Python)
29
+
30
+ The Python SDK for building byte-compatible connectors to Ajar. Types are
31
+ generated from the same vendored `event.proto` as the Rust/Go/C++ SDKs, so this
32
+ SDK reproduces the **same** golden vectors.
33
+
34
+ ## Try the template in 10 seconds
35
+
36
+ ```bash
37
+ cd python
38
+ pip install -e . # or: pip install protobuf cryptography
39
+ echo '{"lat":26.4,"lon":50.9,"alt_m":11000,"quality":0.9}' \
40
+ | PYTHONPATH=. python examples/connector_template.py --dry-run
41
+ # -> 019e... -> ajar.ingest.demo-connector (196 sealed bytes) [dry-run]
42
+ ```
43
+
44
+ You just built and signed a canonical Ajar event. To make it yours, edit the one
45
+ function marked `EDIT` in [examples/connector_template.py](examples/connector_template.py).
46
+
47
+ ## Use it in code
48
+
49
+ ```python
50
+ from ajar_connector import EventBuilder, canonical_bytes, seal, SigningKey
51
+
52
+ event = (
53
+ EventBuilder("acme-radar-1", "mim:aircraft")
54
+ .new_id().now()
55
+ .location(26.4, 50.9, 11000.0)
56
+ .confidence(0.94)
57
+ .build()
58
+ )
59
+ sealed = seal(canonical_bytes(event), SigningKey.from_seed(my_seed)) # 64-byte sig ++ canonical
60
+ ```
61
+
62
+ ## Run the checks
63
+
64
+ ```bash
65
+ cd python
66
+ python -m pytest # unit tests
67
+ PYTHONPATH=. python conformance/golden_vectors.py # the byte-compat gate
68
+ ```
69
+
70
+ ## Streaming example
71
+
72
+ ```bash
73
+ pip install -e ".[examples]" # adds nats-py
74
+ PYTHONPATH=. python examples/synthetic_radar.py # publish to a local Core
75
+ PYTHONPATH=. python examples/synthetic_radar.py --dry-run --ticks 3 # no infra
76
+ ```
77
+
78
+ See the full [onboarding guide](../ONBOARDING.md) for data flow, deployment
79
+ topology, key generation, and troubleshooting.
80
+
81
+ > The conformance gate and `--dry-run` need only `protobuf` + `cryptography`.
82
+ > `nats-py` is an `examples` extra, kept out of the SDK's core dependencies.
@@ -0,0 +1,56 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ # ajar-connector (Python)
3
+
4
+ The Python SDK for building byte-compatible connectors to Ajar. Types are
5
+ generated from the same vendored `event.proto` as the Rust/Go/C++ SDKs, so this
6
+ SDK reproduces the **same** golden vectors.
7
+
8
+ ## Try the template in 10 seconds
9
+
10
+ ```bash
11
+ cd python
12
+ pip install -e . # or: pip install protobuf cryptography
13
+ echo '{"lat":26.4,"lon":50.9,"alt_m":11000,"quality":0.9}' \
14
+ | PYTHONPATH=. python examples/connector_template.py --dry-run
15
+ # -> 019e... -> ajar.ingest.demo-connector (196 sealed bytes) [dry-run]
16
+ ```
17
+
18
+ You just built and signed a canonical Ajar event. To make it yours, edit the one
19
+ function marked `EDIT` in [examples/connector_template.py](examples/connector_template.py).
20
+
21
+ ## Use it in code
22
+
23
+ ```python
24
+ from ajar_connector import EventBuilder, canonical_bytes, seal, SigningKey
25
+
26
+ event = (
27
+ EventBuilder("acme-radar-1", "mim:aircraft")
28
+ .new_id().now()
29
+ .location(26.4, 50.9, 11000.0)
30
+ .confidence(0.94)
31
+ .build()
32
+ )
33
+ sealed = seal(canonical_bytes(event), SigningKey.from_seed(my_seed)) # 64-byte sig ++ canonical
34
+ ```
35
+
36
+ ## Run the checks
37
+
38
+ ```bash
39
+ cd python
40
+ python -m pytest # unit tests
41
+ PYTHONPATH=. python conformance/golden_vectors.py # the byte-compat gate
42
+ ```
43
+
44
+ ## Streaming example
45
+
46
+ ```bash
47
+ pip install -e ".[examples]" # adds nats-py
48
+ PYTHONPATH=. python examples/synthetic_radar.py # publish to a local Core
49
+ PYTHONPATH=. python examples/synthetic_radar.py --dry-run --ticks 3 # no infra
50
+ ```
51
+
52
+ See the full [onboarding guide](../ONBOARDING.md) for data flow, deployment
53
+ topology, key generation, and troubleshooting.
54
+
55
+ > The conformance gate and `--dry-run` need only `protobuf` + `cryptography`.
56
+ > `nats-py` is an `examples` extra, kept out of the SDK's core dependencies.
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """ajar-connector: the Python SDK for building byte-compatible connectors to the
3
+ Ajar integration plane.
4
+
5
+ Types are generated from the same vendored ``event.proto`` as the Rust, Go, and
6
+ C++ SDKs; canonical bytes are the deterministic protobuf encoding that gets
7
+ hashed and signed. The shared ``vendor/contract/vectors.json`` proves this
8
+ SDK's output is byte-identical to the others.
9
+ """
10
+
11
+ from .builder import MAX_ATTRIBUTES, MAX_METADATA, MAX_POLICY_TAGS, BuildError, EventBuilder
12
+ from .canonical import canonical_bytes
13
+ from .connector import Connector, OutboundProfile
14
+ from .event_pb2 import Attribute, Event, GeoPoint
15
+ from .profile import ConnectorProfile
16
+ from .seal import SEAL_SIGNATURE_LEN, SealVerificationError, SigningKey, seal, verify
17
+
18
+ __all__ = [
19
+ "Attribute",
20
+ "BuildError",
21
+ "Connector",
22
+ "ConnectorProfile",
23
+ "Event",
24
+ "EventBuilder",
25
+ "GeoPoint",
26
+ "MAX_ATTRIBUTES",
27
+ "MAX_METADATA",
28
+ "MAX_POLICY_TAGS",
29
+ "OutboundProfile",
30
+ "SEAL_SIGNATURE_LEN",
31
+ "SealVerificationError",
32
+ "SigningKey",
33
+ "canonical_bytes",
34
+ "seal",
35
+ "verify",
36
+ ]
@@ -0,0 +1,169 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Ergonomic, fail-closed construction of canonical Events."""
3
+
4
+ import os
5
+ import time
6
+ from datetime import datetime, timezone
7
+
8
+ from .event_pb2 import Event
9
+
10
+ MAX_ATTRIBUTES = 128
11
+ MAX_METADATA = 128
12
+ MAX_POLICY_TAGS = 64
13
+ _SCHEMA_VERSION = "v1"
14
+
15
+
16
+ class BuildError(Exception):
17
+ """Raised when a canonical invariant or required field is violated."""
18
+
19
+
20
+ def _uuid7() -> str:
21
+ """A UUIDv7 string (48-bit ms timestamp + random), dependency-free."""
22
+ b = bytearray(os.urandom(16))
23
+ ms = int(time.time() * 1000)
24
+ b[0] = (ms >> 40) & 0xFF
25
+ b[1] = (ms >> 32) & 0xFF
26
+ b[2] = (ms >> 24) & 0xFF
27
+ b[3] = (ms >> 16) & 0xFF
28
+ b[4] = (ms >> 8) & 0xFF
29
+ b[5] = ms & 0xFF
30
+ b[6] = (b[6] & 0x0F) | 0x70 # version 7
31
+ b[8] = (b[8] & 0x3F) | 0x80 # RFC 4122 variant
32
+ h = b.hex()
33
+ return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
34
+
35
+
36
+ def _is_namespaced(entity_type: str) -> bool:
37
+ """True if entity_type is mim:<type> or x:<vendor>:<type>."""
38
+ if entity_type.startswith("mim:"):
39
+ rest = entity_type[4:]
40
+ return bool(rest) and ":" not in rest
41
+ if entity_type.startswith("x:"):
42
+ parts = entity_type[2:].split(":")
43
+ return len(parts) == 2 and all(parts)
44
+ return False
45
+
46
+
47
+ class EventBuilder:
48
+ """Builds a canonical Event. Auto-sorts attributes, rejects duplicate keys,
49
+ enforces required fields and limits, offers UUIDv7 / RFC 3339 helpers.
50
+ ``received_at`` is intentionally not exposed — Ajar stamps its own clock.
51
+ """
52
+
53
+ def __init__(self, source_id: str, entity_type: str) -> None:
54
+ self._source_id = source_id
55
+ self._entity_type = entity_type
56
+ self._id: str | None = None
57
+ self._timestamp: str | None = None
58
+ self._location: tuple[float, float, float] | None = None
59
+ self._payload = b""
60
+ self._policy_tags: list[str] = []
61
+ self._confidence = 0.0
62
+ self._attributes: list[tuple[str, str]] = []
63
+ self._metadata: list[tuple[str, str]] = []
64
+ self._strict_entity_namespace = True
65
+
66
+ def id(self, value: str) -> "EventBuilder":
67
+ self._id = value
68
+ return self
69
+
70
+ def new_id(self) -> "EventBuilder":
71
+ self._id = _uuid7()
72
+ return self
73
+
74
+ def timestamp(self, ts: str) -> "EventBuilder":
75
+ self._timestamp = ts
76
+ return self
77
+
78
+ def now(self) -> "EventBuilder":
79
+ self._timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
80
+ return self
81
+
82
+ def location(self, latitude: float, longitude: float, altitude_m: float) -> "EventBuilder":
83
+ self._location = (latitude, longitude, altitude_m)
84
+ return self
85
+
86
+ def payload(self, data: bytes) -> "EventBuilder":
87
+ self._payload = data
88
+ return self
89
+
90
+ def policy_tag(self, tag: str) -> "EventBuilder":
91
+ self._policy_tags.append(tag)
92
+ return self
93
+
94
+ def confidence(self, confidence: float) -> "EventBuilder":
95
+ self._confidence = confidence
96
+ return self
97
+
98
+ def attribute(self, key: str, value: str) -> "EventBuilder":
99
+ self._attributes.append((key, value))
100
+ return self
101
+
102
+ def metadata(self, key: str, value: str) -> "EventBuilder":
103
+ """Add one ungoverned passthrough metadata entry (ADR-0030): a
104
+ vendor-specific field carried and audited opaquely, but not
105
+ type-validated or correlated (e.g. a source's native identifier).
106
+ Order does not matter — ``build`` sorts by key and rejects duplicates.
107
+ """
108
+ self._metadata.append((key, value))
109
+ return self
110
+
111
+ def allow_unnamespaced_entity_type(self) -> "EventBuilder":
112
+ self._strict_entity_namespace = False
113
+ return self
114
+
115
+ def build(self) -> Event:
116
+ """Validate invariants and produce the canonical Event."""
117
+ if not self._id:
118
+ raise BuildError("missing required field: id")
119
+ if not self._timestamp:
120
+ raise BuildError("missing required field: timestamp")
121
+ if not self._source_id:
122
+ raise BuildError("missing required field: source_id")
123
+ if not self._entity_type:
124
+ raise BuildError("missing required field: entity_type")
125
+ if self._strict_entity_namespace and not _is_namespaced(self._entity_type):
126
+ raise BuildError(
127
+ f"entity_type {self._entity_type!r} is not namespaced as "
128
+ "mim:<type> or x:<vendor>:<type>"
129
+ )
130
+ if not (0.0 <= self._confidence <= 1.0):
131
+ raise BuildError(f"confidence {self._confidence} is outside [0.0, 1.0]")
132
+ if len(self._policy_tags) > MAX_POLICY_TAGS:
133
+ raise BuildError(f"too many policy tags: {len(self._policy_tags)} (max {MAX_POLICY_TAGS})")
134
+ if len(self._attributes) > MAX_ATTRIBUTES:
135
+ raise BuildError(f"too many attributes: {len(self._attributes)} (max {MAX_ATTRIBUTES})")
136
+ if len(self._metadata) > MAX_METADATA:
137
+ raise BuildError(f"too many metadata entries: {len(self._metadata)} (max {MAX_METADATA})")
138
+
139
+ # Canonical rule: attributes sorted by key, unique.
140
+ attrs = sorted(self._attributes, key=lambda kv: kv[0])
141
+ for i in range(1, len(attrs)):
142
+ if attrs[i][0] == attrs[i - 1][0]:
143
+ raise BuildError(f"duplicate attribute key: {attrs[i][0]!r}")
144
+
145
+ # Metadata follows the identical canonical rule: sorted by key, unique.
146
+ meta = sorted(self._metadata, key=lambda kv: kv[0])
147
+ for i in range(1, len(meta)):
148
+ if meta[i][0] == meta[i - 1][0]:
149
+ raise BuildError(f"duplicate metadata key: {meta[i][0]!r}")
150
+
151
+ event = Event()
152
+ event.schema_version = _SCHEMA_VERSION
153
+ event.id = self._id
154
+ event.source_id = self._source_id
155
+ event.entity_type = self._entity_type
156
+ event.timestamp = self._timestamp
157
+ # received_at intentionally left empty — Ajar stamps its own clock.
158
+ if self._location is not None:
159
+ event.location.latitude = self._location[0]
160
+ event.location.longitude = self._location[1]
161
+ event.location.altitude_m = self._location[2]
162
+ event.payload = self._payload
163
+ event.policy_tags.extend(self._policy_tags)
164
+ event.confidence = self._confidence
165
+ for key, value in attrs:
166
+ event.attributes.add(key=key, value=value)
167
+ for key, value in meta:
168
+ event.metadata.add(key=key, value=value)
169
+ return event
@@ -0,0 +1,15 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Canonical byte encoding of an Event."""
3
+
4
+ from .event_pb2 import Event
5
+
6
+
7
+ def canonical_bytes(event: Event) -> bytes:
8
+ """Return the canonical protobuf encoding of ``event``.
9
+
10
+ ``deterministic=True`` gives ascending tag order with proto3 defaults
11
+ omitted and map keys sorted (the contract has no maps) — the same wire
12
+ shape prost (Rust), Go, libprotobuf, and nanopb produce. The caller owns
13
+ the attribute invariant (sorted by key, unique); EventBuilder guarantees it.
14
+ """
15
+ return event.SerializeToString(deterministic=True)
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Connector interfaces: inbound normalization and outbound rendering."""
3
+
4
+ import abc
5
+
6
+ from .event_pb2 import Event
7
+
8
+
9
+ class Connector(abc.ABC):
10
+ """An inbound connector: native bytes -> canonical Event.
11
+
12
+ Implementations parse ``native`` (CoT XML, a CSV row, a vendor frame, ...)
13
+ and return a fully-populated event, ideally via EventBuilder.
14
+ """
15
+
16
+ @abc.abstractmethod
17
+ def normalize(self, native: bytes) -> Event:
18
+ ...
19
+
20
+
21
+ class OutboundProfile(abc.ABC):
22
+ """An outbound profile: render a governed Event into a target format.
23
+
24
+ Its honesty is the modeled/lossy field declaration; round-trip conformance
25
+ (canonical -> target -> canonical) checks the claim.
26
+ """
27
+
28
+ @abc.abstractmethod
29
+ def target(self) -> str: ...
30
+
31
+ @abc.abstractmethod
32
+ def slug(self) -> str: ...
33
+
34
+ @abc.abstractmethod
35
+ def version(self) -> str: ...
36
+
37
+ @abc.abstractmethod
38
+ def modeled_fields(self) -> list[str]: ...
39
+
40
+ @abc.abstractmethod
41
+ def lossy_fields(self) -> list[str]: ...
42
+
43
+ @abc.abstractmethod
44
+ def render(self, event: Event) -> bytes: ...
@@ -0,0 +1,40 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: event.proto
5
+ # Protobuf Python Version: 7.35.0
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 7,
15
+ 35,
16
+ 0,
17
+ '',
18
+ 'event.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\rajar.event.v1\"\xba\x02\n\x05\x45vent\x12\x16\n\x0eschema_version\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\t\x12\x11\n\tsource_id\x18\x03 \x01(\t\x12\x13\n\x0b\x65ntity_type\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\t\x12\x13\n\x0breceived_at\x18\n \x01(\t\x12)\n\x08location\x18\x06 \x01(\x0b\x32\x17.ajar.event.v1.GeoPoint\x12\x0f\n\x07payload\x18\x07 \x01(\x0c\x12\x13\n\x0bpolicy_tags\x18\x08 \x03(\t\x12\x12\n\nconfidence\x18\t \x01(\x01\x12,\n\nattributes\x18\x0b \x03(\x0b\x32\x18.ajar.event.v1.Attribute\x12*\n\x08metadata\x18\x0c \x03(\x0b\x32\x18.ajar.event.v1.Attribute\"\'\n\tAttribute\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"C\n\x08GeoPoint\x12\x10\n\x08latitude\x18\x01 \x01(\x01\x12\x11\n\tlongitude\x18\x02 \x01(\x01\x12\x12\n\naltitude_m\x18\x03 \x01(\x01\x62\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ DESCRIPTOR._loaded_options = None
34
+ _globals['_EVENT']._serialized_start=31
35
+ _globals['_EVENT']._serialized_end=345
36
+ _globals['_ATTRIBUTE']._serialized_start=347
37
+ _globals['_ATTRIBUTE']._serialized_end=386
38
+ _globals['_GEOPOINT']._serialized_start=388
39
+ _globals['_GEOPOINT']._serialized_end=455
40
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Connector profile: the self-declaration a connector publishes."""
3
+
4
+ import json
5
+
6
+
7
+ class ConnectorProfile:
8
+ """A connector's published profile. Serializes deterministically (verifying
9
+ key as lowercase hex); byte-identical to the Rust/Go/C++ profile JSON.
10
+ """
11
+
12
+ def __init__(self, source_id: str, verifying_key: bytes) -> None:
13
+ self.source_id = source_id
14
+ self.allowed_entity_types: list[str] = []
15
+ self.max_payload_bytes = 64 * 1024
16
+ self.rate_capacity = 100
17
+ self.rate_refill_per_sec = 10.0
18
+ self.verifying_key = verifying_key
19
+
20
+ def allow_entity_type(self, entity_type: str) -> "ConnectorProfile":
21
+ self.allowed_entity_types.append(entity_type)
22
+ return self
23
+
24
+ def with_max_payload_bytes(self, max_bytes: int) -> "ConnectorProfile":
25
+ self.max_payload_bytes = max_bytes
26
+ return self
27
+
28
+ def rate_limit(self, capacity: int, refill_per_sec: float) -> "ConnectorProfile":
29
+ self.rate_capacity = capacity
30
+ self.rate_refill_per_sec = refill_per_sec
31
+ return self
32
+
33
+ def _wire(self) -> dict:
34
+ return {
35
+ "source_id": self.source_id,
36
+ "allowed_entity_types": self.allowed_entity_types,
37
+ "max_payload_bytes": self.max_payload_bytes,
38
+ "rate_capacity": self.rate_capacity,
39
+ "rate_refill_per_sec": self.rate_refill_per_sec,
40
+ "verifying_key_hex": self.verifying_key.hex(),
41
+ }
42
+
43
+ def to_json(self) -> str:
44
+ """Compact JSON."""
45
+ return json.dumps(self._wire(), separators=(",", ":"))
46
+
47
+ def to_json_pretty(self) -> str:
48
+ """Indented JSON."""
49
+ return json.dumps(self._wire(), indent=2)
@@ -0,0 +1,92 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The seal envelope: detached Ed25519 signature prefixed to canonical bytes."""
3
+
4
+ from cryptography.exceptions import InvalidSignature
5
+ from cryptography.hazmat.primitives import serialization
6
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
7
+ Ed25519PrivateKey,
8
+ Ed25519PublicKey,
9
+ )
10
+
11
+ SEAL_SIGNATURE_LEN = 64
12
+ """Length in bytes of the Ed25519 signature prefix on a sealed envelope."""
13
+
14
+
15
+ class SigningKey:
16
+ """An Ed25519 signing key.
17
+
18
+ Each production connector holds its own; Ajar registers the matching
19
+ verifying key in the connector's profile. The golden-vector seed (32 x
20
+ 0x47) is a published TEST seed — never sign production events with it.
21
+ """
22
+
23
+ def __init__(self, key: Ed25519PrivateKey) -> None:
24
+ self._key = key
25
+
26
+ @classmethod
27
+ def from_seed(cls, seed: bytes) -> "SigningKey":
28
+ """Derive a key from a 32-byte seed."""
29
+ if len(seed) != 32:
30
+ raise ValueError(f"signing seed must be 32 bytes, got {len(seed)}")
31
+ return cls(Ed25519PrivateKey.from_private_bytes(seed))
32
+
33
+ @property
34
+ def verifying_key(self) -> bytes:
35
+ """The 32-byte Ed25519 public (verifying) key."""
36
+ return self._key.public_key().public_bytes(
37
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
38
+ )
39
+
40
+ def sign(self, data: bytes) -> bytes:
41
+ """Return the 64-byte detached signature over ``data``."""
42
+ return self._key.sign(data)
43
+
44
+
45
+ def seal(canonical: bytes, key: SigningKey) -> bytes:
46
+ """Seal canonical bytes: ``sign(key, canonical) ++ canonical``.
47
+
48
+ The verifier splits at :data:`SEAL_SIGNATURE_LEN`, checks the signature over
49
+ the remainder with the connector's registered verifying key, and recovers
50
+ the canonical event from the suffix.
51
+ """
52
+ return key.sign(canonical) + canonical
53
+
54
+
55
+ class SealVerificationError(Exception):
56
+ """A sealed envelope was not accepted."""
57
+
58
+
59
+ def verify(sealed: bytes, verifying_key: bytes) -> bytes:
60
+ """Verify a sealed envelope and return the canonical bytes it carries.
61
+
62
+ The inverse of :func:`seal`, and the whole trust model in one call: it
63
+ answers whether these exact bytes were sealed by the holder of
64
+ ``verifying_key`` and are unaltered since. A recipient holding a
65
+ connector's registered verifying key can establish provenance without the
66
+ connector, the broker or Ajar Core being present or trusted.
67
+
68
+ ``verifying_key`` is the 32-byte raw Ed25519 public key, as published in the
69
+ connector's profile.
70
+
71
+ Raises :class:`SealVerificationError` if the envelope is too short, the key
72
+ is not a valid Ed25519 public key, or the signature does not verify.
73
+ """
74
+ if len(verifying_key) != 32:
75
+ raise SealVerificationError(
76
+ f"verifying key must be 32 bytes, got {len(verifying_key)}"
77
+ )
78
+ if len(sealed) < SEAL_SIGNATURE_LEN:
79
+ raise SealVerificationError(
80
+ f"sealed envelope is {len(sealed)} bytes, shorter than the "
81
+ f"{SEAL_SIGNATURE_LEN}-byte signature"
82
+ )
83
+ signature, canonical = sealed[:SEAL_SIGNATURE_LEN], sealed[SEAL_SIGNATURE_LEN:]
84
+ try:
85
+ Ed25519PublicKey.from_public_bytes(verifying_key).verify(signature, canonical)
86
+ except InvalidSignature as exc:
87
+ raise SealVerificationError(
88
+ "signature did not verify under the given key"
89
+ ) from exc
90
+ except ValueError as exc:
91
+ raise SealVerificationError(f"invalid verifying key: {exc}") from exc
92
+ return canonical
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: ajar-connector
3
+ Version: 0.5.3
4
+ Summary: Python SDK for building byte-compatible connectors to the Ajar integration plane.
5
+ Author: Ajar connectors contributors
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/promaka/ajar-connectors
8
+ Project-URL: Documentation, https://github.com/promaka/ajar-connectors/blob/main/docs/embedding-python.md
9
+ Project-URL: Wire contract, https://github.com/promaka/ajar-connectors/blob/main/docs/wire-contract-v1.md
10
+ Project-URL: Source, https://github.com/promaka/ajar-connectors
11
+ Project-URL: Changelog, https://github.com/promaka/ajar-connectors/blob/main/CHANGELOG.md
12
+ Keywords: ajar,connector,defence,interoperability,protobuf,ed25519
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Networking
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: protobuf<8,>=7.35
21
+ Requires-Dist: cryptography>=42
22
+ Provides-Extra: examples
23
+ Requires-Dist: nats-py>=2.6; extra == "examples"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+
27
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
28
+ # ajar-connector (Python)
29
+
30
+ The Python SDK for building byte-compatible connectors to Ajar. Types are
31
+ generated from the same vendored `event.proto` as the Rust/Go/C++ SDKs, so this
32
+ SDK reproduces the **same** golden vectors.
33
+
34
+ ## Try the template in 10 seconds
35
+
36
+ ```bash
37
+ cd python
38
+ pip install -e . # or: pip install protobuf cryptography
39
+ echo '{"lat":26.4,"lon":50.9,"alt_m":11000,"quality":0.9}' \
40
+ | PYTHONPATH=. python examples/connector_template.py --dry-run
41
+ # -> 019e... -> ajar.ingest.demo-connector (196 sealed bytes) [dry-run]
42
+ ```
43
+
44
+ You just built and signed a canonical Ajar event. To make it yours, edit the one
45
+ function marked `EDIT` in [examples/connector_template.py](examples/connector_template.py).
46
+
47
+ ## Use it in code
48
+
49
+ ```python
50
+ from ajar_connector import EventBuilder, canonical_bytes, seal, SigningKey
51
+
52
+ event = (
53
+ EventBuilder("acme-radar-1", "mim:aircraft")
54
+ .new_id().now()
55
+ .location(26.4, 50.9, 11000.0)
56
+ .confidence(0.94)
57
+ .build()
58
+ )
59
+ sealed = seal(canonical_bytes(event), SigningKey.from_seed(my_seed)) # 64-byte sig ++ canonical
60
+ ```
61
+
62
+ ## Run the checks
63
+
64
+ ```bash
65
+ cd python
66
+ python -m pytest # unit tests
67
+ PYTHONPATH=. python conformance/golden_vectors.py # the byte-compat gate
68
+ ```
69
+
70
+ ## Streaming example
71
+
72
+ ```bash
73
+ pip install -e ".[examples]" # adds nats-py
74
+ PYTHONPATH=. python examples/synthetic_radar.py # publish to a local Core
75
+ PYTHONPATH=. python examples/synthetic_radar.py --dry-run --ticks 3 # no infra
76
+ ```
77
+
78
+ See the full [onboarding guide](../ONBOARDING.md) for data flow, deployment
79
+ topology, key generation, and troubleshooting.
80
+
81
+ > The conformance gate and `--dry-run` need only `protobuf` + `cryptography`.
82
+ > `nats-py` is an `examples` extra, kept out of the SDK's core dependencies.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ ajar_connector/__init__.py
4
+ ajar_connector/builder.py
5
+ ajar_connector/canonical.py
6
+ ajar_connector/connector.py
7
+ ajar_connector/event_pb2.py
8
+ ajar_connector/profile.py
9
+ ajar_connector/seal.py
10
+ ajar_connector.egg-info/PKG-INFO
11
+ ajar_connector.egg-info/SOURCES.txt
12
+ ajar_connector.egg-info/dependency_links.txt
13
+ ajar_connector.egg-info/requires.txt
14
+ ajar_connector.egg-info/top_level.txt
15
+ tests/test_sdk.py
@@ -0,0 +1,8 @@
1
+ protobuf<8,>=7.35
2
+ cryptography>=42
3
+
4
+ [dev]
5
+ pytest>=8
6
+
7
+ [examples]
8
+ nats-py>=2.6
@@ -0,0 +1 @@
1
+ ajar_connector
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ [build-system]
3
+ requires = ["setuptools>=61"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "ajar-connector"
8
+ version = "0.5.3"
9
+ description = "Python SDK for building byte-compatible connectors to the Ajar integration plane."
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = "Apache-2.0"
13
+ authors = [{ name = "Ajar connectors contributors" }]
14
+ keywords = ["ajar", "connector", "defence", "interoperability", "protobuf", "ed25519"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Topic :: System :: Networking",
21
+ ]
22
+
23
+ dependencies = [
24
+ "protobuf>=7.35,<8",
25
+ "cryptography>=42",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/promaka/ajar-connectors"
30
+ Documentation = "https://github.com/promaka/ajar-connectors/blob/main/docs/embedding-python.md"
31
+ "Wire contract" = "https://github.com/promaka/ajar-connectors/blob/main/docs/wire-contract-v1.md"
32
+ Source = "https://github.com/promaka/ajar-connectors"
33
+ Changelog = "https://github.com/promaka/ajar-connectors/blob/main/CHANGELOG.md"
34
+
35
+ [project.optional-dependencies]
36
+ # Transport for the example connectors — kept OUT of the SDK's core deps.
37
+ examples = ["nats-py>=2.6"]
38
+ dev = ["pytest>=8"]
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["."]
42
+ include = ["ajar_connector*"]
43
+
44
+ [tool.pytest.ini_options]
45
+ # Make ajar_connector importable from the repo without an install step.
46
+ pythonpath = ["."]
47
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Unit tests for the builder, seal, and profile (run with pytest)."""
3
+
4
+ import pytest
5
+
6
+ from ajar_connector import (
7
+ SEAL_SIGNATURE_LEN,
8
+ BuildError,
9
+ canonical_bytes,
10
+ ConnectorProfile,
11
+ EventBuilder,
12
+ SigningKey,
13
+ seal,
14
+ SealVerificationError,
15
+ verify,
16
+ )
17
+
18
+
19
+ def test_builder_sorts_attributes_and_sets_defaults():
20
+ e = (
21
+ EventBuilder("sensor-1", "mim:drone")
22
+ .new_id()
23
+ .now()
24
+ .attribute("speed", "110")
25
+ .attribute("heading", "225")
26
+ .build()
27
+ )
28
+ assert e.schema_version == "v1"
29
+ assert e.received_at == ""
30
+ assert [a.key for a in e.attributes] == ["heading", "speed"]
31
+
32
+
33
+ def test_builder_rejects_duplicate_attribute_keys():
34
+ with pytest.raises(BuildError, match="duplicate attribute key"):
35
+ EventBuilder("sensor-1", "mim:drone").new_id().now().attribute(
36
+ "heading", "225"
37
+ ).attribute("heading", "226").build()
38
+
39
+
40
+ def test_builder_requires_id_and_timestamp():
41
+ with pytest.raises(BuildError, match="missing required field: id"):
42
+ EventBuilder("sensor-1", "mim:drone").build()
43
+
44
+
45
+ def test_builder_rejects_out_of_range_confidence():
46
+ with pytest.raises(BuildError, match="confidence"):
47
+ EventBuilder("sensor-1", "mim:drone").new_id().now().confidence(1.5).build()
48
+
49
+
50
+ def test_builder_enforces_namespacing_with_opt_out():
51
+ with pytest.raises(BuildError, match="not namespaced"):
52
+ EventBuilder("sensor-1", "drone").new_id().now().build()
53
+ # opt-out builds fine
54
+ EventBuilder("sensor-1", "drone").new_id().now().allow_unnamespaced_entity_type().build()
55
+
56
+
57
+ def test_seal_layout():
58
+ key = SigningKey.from_seed(bytes([0x47]) * 32)
59
+ canonical = b"hello ajar"
60
+ sealed = seal(canonical, key)
61
+ assert len(sealed) == SEAL_SIGNATURE_LEN + len(canonical)
62
+ assert sealed[SEAL_SIGNATURE_LEN:] == canonical
63
+
64
+
65
+ def test_profile_json_serializes_verifying_key_as_hex():
66
+ vk = SigningKey.from_seed(bytes([0x47]) * 32).verifying_key
67
+ profile = (
68
+ ConnectorProfile("sensor-123", vk)
69
+ .allow_entity_type("mim:drone")
70
+ .rate_limit(50, 5.0)
71
+ )
72
+ js = profile.to_json()
73
+ assert '"source_id":"sensor-123"' in js
74
+ assert "mim:drone" in js
75
+ assert vk.hex() in js
76
+ assert '"rate_refill_per_sec":5.0' in js # float keeps the .0, like the other SDKs
77
+
78
+
79
+ def test_verify_round_trips_and_returns_canonical_bytes():
80
+ key = SigningKey.from_seed(bytes([0x47] * 32))
81
+ event = EventBuilder("acme-radar-1", "mim:aircraft").new_id().now().build()
82
+ canonical = canonical_bytes(event)
83
+ assert verify(seal(canonical, key), key.verifying_key) == canonical
84
+
85
+
86
+ def test_verify_rejects_tampering_wrong_key_and_truncation():
87
+ key = SigningKey.from_seed(bytes([0x47] * 32))
88
+ other = SigningKey.from_seed(bytes([0x11] * 32))
89
+ sealed = seal(canonical_bytes(
90
+ EventBuilder("acme-radar-1", "mim:aircraft").new_id().now().build()
91
+ ), key)
92
+
93
+ tampered = bytearray(sealed)
94
+ tampered[-1] ^= 0x01
95
+ with pytest.raises(SealVerificationError):
96
+ verify(bytes(tampered), key.verifying_key)
97
+
98
+ with pytest.raises(SealVerificationError):
99
+ verify(sealed, other.verifying_key)
100
+
101
+ with pytest.raises(SealVerificationError):
102
+ verify(sealed[: SEAL_SIGNATURE_LEN - 1], key.verifying_key)
103
+
104
+ with pytest.raises(SealVerificationError):
105
+ verify(sealed, b"too-short-key")