smart-data-engine-sdk 0.1.0.dev0__py3-none-any.whl
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.
- sde/__init__.py +226 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/engines/__init__.py +0 -0
- sde/engines/clickhouse.py +689 -0
- sde/engines/orderbook.py +454 -0
- sde/engines/postgres.py +672 -0
- sde/entity.py +170 -0
- sde/errors.py +88 -0
- sde/explain.py +300 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/infer.py +461 -0
- sde/internal.py +90 -0
- sde/layout.py +660 -0
- sde/logging.py +132 -0
- sde/migration.py +820 -0
- sde/model.py +482 -0
- sde/placement.py +818 -0
- sde/py.typed +0 -0
- sde/routing.py +85 -0
- sde/schema.py +370 -0
- sde/session.py +507 -0
- sde/shapes.py +153 -0
- sde/telemetry.py +736 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +318 -0
- sde/types.py +228 -0
- sde/watermark.py +222 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/METADATA +152 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/RECORD +35 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/NOTICE +13 -0
sde/__init__.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Smart Data Engine - client library.
|
|
2
|
+
|
|
3
|
+
You declare entities and relations. We decide which database engine each colocation group lives in,
|
|
4
|
+
what its physical layout is there, and when it should move - and your code never names a table or an
|
|
5
|
+
engine, which is exactly what lets us change both without touching it.
|
|
6
|
+
|
|
7
|
+
from datetime import datetime from decimal import Decimal from typing import Annotated from uuid
|
|
8
|
+
import UUID import sde
|
|
9
|
+
|
|
10
|
+
@sde.entity
|
|
11
|
+
class User:
|
|
12
|
+
id: UUID email: str
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
pii = ["email"]
|
|
16
|
+
|
|
17
|
+
@sde.entity
|
|
18
|
+
class Order:
|
|
19
|
+
id: UUID user: sde.Ref[User] total: Annotated[Decimal, sde.precision(12, 2)] created_at:
|
|
20
|
+
datetime
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
atomic_with = ["Payment"] residency = "EU"
|
|
24
|
+
|
|
25
|
+
Everything about storage is our decision. The four things you declare - atomicity, residency,
|
|
26
|
+
personal data and a cost ceiling - are the ones that cannot be read from traffic no matter how long
|
|
27
|
+
we watch it.
|
|
28
|
+
|
|
29
|
+
This library is Apache-2.0 and it works without an account: hand it a placement map you wrote
|
|
30
|
+
yourself and it will route, create schema and run, with no key and no network. That is a supported
|
|
31
|
+
mode, not a loophole.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from .canonical import CanonicalError, canonical_bytes, canonical_str, digest16
|
|
37
|
+
from .capabilities import members_of, satisfies
|
|
38
|
+
from .entity import Ref, clear_registry, entity, registry
|
|
39
|
+
from .errors import (
|
|
40
|
+
DeclarationError,
|
|
41
|
+
EngineError,
|
|
42
|
+
MapError,
|
|
43
|
+
MapRolledBack,
|
|
44
|
+
MigrationRefused,
|
|
45
|
+
ModelPlanningError,
|
|
46
|
+
SdeError,
|
|
47
|
+
)
|
|
48
|
+
from .explain import Cost, Explains, PlanFinding, QueryPlan, QueryPlanRefused, explain
|
|
49
|
+
from .groups import Group, colocation_groups, group_of
|
|
50
|
+
from .hashing import NameMap, hash_identifiers, load_or_create_salt
|
|
51
|
+
from .infer import InferredModel, Note, infer_model, infer_models
|
|
52
|
+
from .internal import internal_failures, reset_internal_failures
|
|
53
|
+
from .layout import (
|
|
54
|
+
DIALECTS,
|
|
55
|
+
FIXED_SCHEMA,
|
|
56
|
+
ORDERBOOK_KEY,
|
|
57
|
+
ORDERBOOK_SHAPE,
|
|
58
|
+
ORDERBOOK_TABLE,
|
|
59
|
+
DerivedLayout,
|
|
60
|
+
can_store,
|
|
61
|
+
default_layout,
|
|
62
|
+
denormalized_layout,
|
|
63
|
+
fixed_schema_mismatch,
|
|
64
|
+
group_columns,
|
|
65
|
+
snake_case,
|
|
66
|
+
stored_types,
|
|
67
|
+
)
|
|
68
|
+
from .migration import (
|
|
69
|
+
BACKFILL_TABLE,
|
|
70
|
+
CHUNK_ROWS,
|
|
71
|
+
DIALECT_PRECISION,
|
|
72
|
+
PRECISION_INDEPENDENT,
|
|
73
|
+
BackfillProgress,
|
|
74
|
+
Difference,
|
|
75
|
+
EntityProgress,
|
|
76
|
+
Migratable,
|
|
77
|
+
VerifyReport,
|
|
78
|
+
backfill,
|
|
79
|
+
precision_refusal,
|
|
80
|
+
verify,
|
|
81
|
+
)
|
|
82
|
+
from .model import CONTRACT, LogicalModel, build_model, neutral_declaration
|
|
83
|
+
from .placement import (
|
|
84
|
+
ALSO_WRITE_SINCE,
|
|
85
|
+
MAP_CONTRACT,
|
|
86
|
+
MAP_CONTRACT_FLOOR,
|
|
87
|
+
RESERVED_TABLES,
|
|
88
|
+
GroupPlacement,
|
|
89
|
+
Materialization,
|
|
90
|
+
PhysicalLayout,
|
|
91
|
+
PlacementMap,
|
|
92
|
+
load_map,
|
|
93
|
+
)
|
|
94
|
+
from .routing import Router, resolve
|
|
95
|
+
from .schema import CompatibilityViews, compatibility_views, schema_is_fixed, schema_statements
|
|
96
|
+
from .session import Engine, Session
|
|
97
|
+
from .shapes import SHAPE_KINDS, WRITE_KINDS, OperationShape, enumerate_shapes
|
|
98
|
+
from .telemetry import (
|
|
99
|
+
MEASURED_FIELDS,
|
|
100
|
+
CopyFreshness,
|
|
101
|
+
FanOutStats,
|
|
102
|
+
GroupFeatures,
|
|
103
|
+
Histogram,
|
|
104
|
+
Recorder,
|
|
105
|
+
ShapeStats,
|
|
106
|
+
Window,
|
|
107
|
+
has_time_dimension,
|
|
108
|
+
)
|
|
109
|
+
from .types import Float32, Int32, Json, Timestamp, precision
|
|
110
|
+
from .watermark import (
|
|
111
|
+
WATERMARK_TABLE,
|
|
112
|
+
Protection,
|
|
113
|
+
WatermarkCheck,
|
|
114
|
+
WatermarkStore,
|
|
115
|
+
enforce_forward_only,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
__version__ = "0.1.0.dev0"
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"ALSO_WRITE_SINCE",
|
|
122
|
+
"BACKFILL_TABLE",
|
|
123
|
+
"CHUNK_ROWS",
|
|
124
|
+
"CONTRACT",
|
|
125
|
+
"DIALECTS",
|
|
126
|
+
"DIALECT_PRECISION",
|
|
127
|
+
"FIXED_SCHEMA",
|
|
128
|
+
"MAP_CONTRACT",
|
|
129
|
+
"MAP_CONTRACT_FLOOR",
|
|
130
|
+
"MEASURED_FIELDS",
|
|
131
|
+
"ORDERBOOK_KEY",
|
|
132
|
+
"ORDERBOOK_SHAPE",
|
|
133
|
+
"ORDERBOOK_TABLE",
|
|
134
|
+
"PRECISION_INDEPENDENT",
|
|
135
|
+
"RESERVED_TABLES",
|
|
136
|
+
"SHAPE_KINDS",
|
|
137
|
+
"WATERMARK_TABLE",
|
|
138
|
+
"WRITE_KINDS",
|
|
139
|
+
"BackfillProgress",
|
|
140
|
+
"CanonicalError",
|
|
141
|
+
"CompatibilityViews",
|
|
142
|
+
"CopyFreshness",
|
|
143
|
+
"Cost",
|
|
144
|
+
"DeclarationError",
|
|
145
|
+
"DerivedLayout",
|
|
146
|
+
"Difference",
|
|
147
|
+
"Engine",
|
|
148
|
+
"EngineError",
|
|
149
|
+
"EntityProgress",
|
|
150
|
+
"Explains",
|
|
151
|
+
"FanOutStats",
|
|
152
|
+
"Float32",
|
|
153
|
+
"Group",
|
|
154
|
+
"GroupFeatures",
|
|
155
|
+
"GroupPlacement",
|
|
156
|
+
"Histogram",
|
|
157
|
+
"InferredModel",
|
|
158
|
+
"Int32",
|
|
159
|
+
"Json",
|
|
160
|
+
"LogicalModel",
|
|
161
|
+
"MapError",
|
|
162
|
+
"MapRolledBack",
|
|
163
|
+
"Materialization",
|
|
164
|
+
"Migratable",
|
|
165
|
+
"MigrationRefused",
|
|
166
|
+
"ModelPlanningError",
|
|
167
|
+
"NameMap",
|
|
168
|
+
"Note",
|
|
169
|
+
"OperationShape",
|
|
170
|
+
"PhysicalLayout",
|
|
171
|
+
"PlacementMap",
|
|
172
|
+
"PlanFinding",
|
|
173
|
+
"Protection",
|
|
174
|
+
"QueryPlan",
|
|
175
|
+
"QueryPlanRefused",
|
|
176
|
+
"Recorder",
|
|
177
|
+
"Ref",
|
|
178
|
+
"Router",
|
|
179
|
+
"SdeError",
|
|
180
|
+
"Session",
|
|
181
|
+
"ShapeStats",
|
|
182
|
+
"Timestamp",
|
|
183
|
+
"VerifyReport",
|
|
184
|
+
"WatermarkCheck",
|
|
185
|
+
"WatermarkStore",
|
|
186
|
+
"Window",
|
|
187
|
+
"__version__",
|
|
188
|
+
"backfill",
|
|
189
|
+
"build_model",
|
|
190
|
+
"can_store",
|
|
191
|
+
"canonical_bytes",
|
|
192
|
+
"canonical_str",
|
|
193
|
+
"clear_registry",
|
|
194
|
+
"colocation_groups",
|
|
195
|
+
"compatibility_views",
|
|
196
|
+
"default_layout",
|
|
197
|
+
"denormalized_layout",
|
|
198
|
+
"digest16",
|
|
199
|
+
"enforce_forward_only",
|
|
200
|
+
"entity",
|
|
201
|
+
"enumerate_shapes",
|
|
202
|
+
"explain",
|
|
203
|
+
"fixed_schema_mismatch",
|
|
204
|
+
"group_columns",
|
|
205
|
+
"group_of",
|
|
206
|
+
"has_time_dimension",
|
|
207
|
+
"hash_identifiers",
|
|
208
|
+
"infer_model",
|
|
209
|
+
"infer_models",
|
|
210
|
+
"internal_failures",
|
|
211
|
+
"load_map",
|
|
212
|
+
"load_or_create_salt",
|
|
213
|
+
"members_of",
|
|
214
|
+
"neutral_declaration",
|
|
215
|
+
"precision",
|
|
216
|
+
"precision_refusal",
|
|
217
|
+
"registry",
|
|
218
|
+
"reset_internal_failures",
|
|
219
|
+
"resolve",
|
|
220
|
+
"satisfies",
|
|
221
|
+
"schema_is_fixed",
|
|
222
|
+
"schema_statements",
|
|
223
|
+
"snake_case",
|
|
224
|
+
"stored_types",
|
|
225
|
+
"verify",
|
|
226
|
+
]
|
sde/canonical.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Canonical encoding: the one place where the cross-language contract lives.
|
|
2
|
+
|
|
3
|
+
Every SDE library has to produce byte-identical output from this. A definition amounting to
|
|
4
|
+
"whatever the Python implementation does" is not a definition, so the rules are spelled out here and
|
|
5
|
+
in ``docs/format-contract.md``, and the conformance vectors pin them.
|
|
6
|
+
|
|
7
|
+
The rules, in the order they are applied:
|
|
8
|
+
|
|
9
|
+
1. UTF-8, no byte order mark.
|
|
10
|
+
2. Object keys are NFC-normalised, then sorted by Unicode code point. Normalise first: sorting first
|
|
11
|
+
would order ``é`` (U+00E9) and ``e`` + U+0301 differently while both normalise to the same key.
|
|
12
|
+
3. No insignificant whitespace. ``{"a":1,"b":[2,3]}`` and nothing else.
|
|
13
|
+
4. Every string is NFC-normalised.
|
|
14
|
+
5. Escaping is minimal and exhaustive: only ``"``, ``\\`` and C0 control characters are escaped,
|
|
15
|
+
controls using the short forms where JSON defines them and ``\\u00XX`` otherwise. Everything else
|
|
16
|
+
is emitted as raw UTF-8. This matters because JSON writers disagree: some escape all non-ASCII,
|
|
17
|
+
some escape U+2028, some escape forward slashes. Any of those would change the hash.
|
|
18
|
+
6. Floating point values are rejected outright. Their textual form differs across languages and the
|
|
19
|
+
difference is unfixable after the fact. Note the distinction that cost a spec revision: a *field*
|
|
20
|
+
may have type ``float64``; the IR records the *name* of that type, which is a string. There is no
|
|
21
|
+
float literal anywhere in the encoding.
|
|
22
|
+
7. Integers are emitted as their shortest decimal form, no leading ``+``, no exponent.
|
|
23
|
+
|
|
24
|
+
Ordering of arrays is not this module's business. Where order carries no meaning the caller sorts;
|
|
25
|
+
where it does, the caller records an explicit index in each element rather than relying on position.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import unicodedata
|
|
32
|
+
from typing import Final
|
|
33
|
+
|
|
34
|
+
__all__ = ["CanonicalError", "canonical_bytes", "canonical_str", "digest16"]
|
|
35
|
+
|
|
36
|
+
# Short escape forms JSON defines. Everything else below 0x20 gets \u00XX.
|
|
37
|
+
_SHORT_ESCAPES: Final[dict[int, str]] = {
|
|
38
|
+
0x08: "\\b",
|
|
39
|
+
0x09: "\\t",
|
|
40
|
+
0x0A: "\\n",
|
|
41
|
+
0x0C: "\\f",
|
|
42
|
+
0x0D: "\\r",
|
|
43
|
+
0x22: '\\"',
|
|
44
|
+
0x5C: "\\\\",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CanonicalError(ValueError):
|
|
49
|
+
"""A value cannot be encoded canonically.
|
|
50
|
+
|
|
51
|
+
Raised rather than coerced on purpose: silently accepting a float, a NaN or a non-string key
|
|
52
|
+
would produce bytes that another implementation cannot reproduce, and the failure would surface
|
|
53
|
+
much later as two versions of the same model.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _escape(text: str) -> str:
|
|
58
|
+
normalised = unicodedata.normalize("NFC", text)
|
|
59
|
+
out: list[str] = ['"']
|
|
60
|
+
for char in normalised:
|
|
61
|
+
code = ord(char)
|
|
62
|
+
short = _SHORT_ESCAPES.get(code)
|
|
63
|
+
if short is not None:
|
|
64
|
+
out.append(short)
|
|
65
|
+
elif code < 0x20:
|
|
66
|
+
out.append(f"\\u{code:04x}")
|
|
67
|
+
else:
|
|
68
|
+
out.append(char)
|
|
69
|
+
out.append('"')
|
|
70
|
+
return "".join(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _encode(value: object, path: str) -> str:
|
|
74
|
+
# bool before int: bool is a subclass of int in Python and would otherwise encode as 1/0.
|
|
75
|
+
if value is True:
|
|
76
|
+
return "true"
|
|
77
|
+
if value is False:
|
|
78
|
+
return "false"
|
|
79
|
+
if value is None:
|
|
80
|
+
return "null"
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
return _escape(value)
|
|
83
|
+
if isinstance(value, int):
|
|
84
|
+
return str(value)
|
|
85
|
+
if isinstance(value, float):
|
|
86
|
+
raise CanonicalError(
|
|
87
|
+
f"float at {path}: floating point is not representable in canonical form, "
|
|
88
|
+
"because its textual form differs between languages. Use an integer, or a decimal "
|
|
89
|
+
"string, or the name of a float type if you meant to describe a type."
|
|
90
|
+
)
|
|
91
|
+
if isinstance(value, (list, tuple)):
|
|
92
|
+
items = [_encode(item, f"{path}[{i}]") for i, item in enumerate(value)]
|
|
93
|
+
return "[" + ",".join(items) + "]"
|
|
94
|
+
if isinstance(value, dict):
|
|
95
|
+
pairs: list[tuple[str, object]] = []
|
|
96
|
+
for key, item in value.items():
|
|
97
|
+
if not isinstance(key, str):
|
|
98
|
+
raise CanonicalError(
|
|
99
|
+
f"non-string key {key!r} at {path}: object keys must be strings, because "
|
|
100
|
+
"key ordering is defined over Unicode code points"
|
|
101
|
+
)
|
|
102
|
+
pairs.append((unicodedata.normalize("NFC", key), item))
|
|
103
|
+
# Sort after normalising, on the normalised key.
|
|
104
|
+
pairs.sort(key=lambda pair: pair[0])
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
for key, item in pairs:
|
|
108
|
+
if key in seen:
|
|
109
|
+
raise CanonicalError(
|
|
110
|
+
f"duplicate key {key!r} at {path} after NFC normalisation: two keys that "
|
|
111
|
+
"differ "
|
|
112
|
+
"only in Unicode composition are the same key here"
|
|
113
|
+
)
|
|
114
|
+
seen.add(key)
|
|
115
|
+
parts.append(_escape(key) + ":" + _encode(item, f"{path}.{key}"))
|
|
116
|
+
return "{" + ",".join(parts) + "}"
|
|
117
|
+
raise CanonicalError(
|
|
118
|
+
f"{type(value).__name__} at {path} has no canonical form. The canonical encoding accepts "
|
|
119
|
+
"only null, bool, int, str, list and dict; anything richer has to be reduced to those by "
|
|
120
|
+
"the caller, so that the reduction is visible and testable."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def canonical_str(value: object) -> str:
|
|
125
|
+
"""Canonical form as text. Prefer :func:`canonical_bytes` for hashing."""
|
|
126
|
+
return _encode(value, "$")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def canonical_bytes(value: object) -> bytes:
|
|
130
|
+
"""Canonical form as UTF-8 bytes. This is what gets hashed and what vectors compare."""
|
|
131
|
+
return canonical_str(value).encode("utf-8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def digest16(value: object) -> str:
|
|
135
|
+
"""The identifier form used for ``model_version`` and ``shape.id``.
|
|
136
|
+
|
|
137
|
+
Lowercase hex, first 8 bytes of SHA-256 over the canonical bytes. Sixteen characters is short
|
|
138
|
+
enough to appear in logs and error messages without wrapping, and 64 bits of collision
|
|
139
|
+
resistance is ample for the number of models and shapes one application declares.
|
|
140
|
+
"""
|
|
141
|
+
return hashlib.sha256(canonical_bytes(value)).hexdigest()[:16]
|
sde/capabilities.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Asking an engine adapter whether it will answer a call, which is not the same as its type.
|
|
2
|
+
|
|
3
|
+
Two optional protocols decide whether an engine takes part in something: :class:`
|
|
4
|
+
sde.watermark.WatermarkStore` for the forward-only map check, and :class:`sde.migration.Migratable`
|
|
5
|
+
for a migration. Both were originally asked with ``isinstance(engine, Protocol)``, which is the
|
|
6
|
+
obvious spelling and the wrong question.
|
|
7
|
+
|
|
8
|
+
A ``runtime_checkable`` protocol resolves its members with ``hasattr`` up to Python 3.11 and with
|
|
9
|
+
:func:`inspect.getattr_static` from 3.12 onwards - and the second deliberately ignores
|
|
10
|
+
``__getattr__``. So an object that forwards to a wrapped adapter answers ``hasattr`` for every
|
|
11
|
+
member of the protocol, passes ``isinstance`` on 3.11, and fails it on 3.12. This library supports
|
|
12
|
+
and tests all three, which makes that **the same client, the same wrapper, and a different answer
|
|
13
|
+
per interpreter**.
|
|
14
|
+
|
|
15
|
+
Wrapping an engine adapter is an ordinary thing for a client to do - metrics, logging, a retry, a
|
|
16
|
+
connection pool - and the consequence was two wrong diagnoses shipped as helpful messages: "this
|
|
17
|
+
engine has nowhere to keep the bookkeeping, so you have no rollback protection", and "this engine
|
|
18
|
+
cannot take part in a migration". Both named the client's *engine* for a property of their own
|
|
19
|
+
wrapper, and the second refuses a migration outright.
|
|
20
|
+
|
|
21
|
+
Found from a test, not from review: a two-line proxy over the real ClickHouse adapter, written to
|
|
22
|
+
make one write fail, was refused as an engine with no row-level operations. The version dependence
|
|
23
|
+
came from CI on 3.11, which is the part no single machine would have shown.
|
|
24
|
+
|
|
25
|
+
So the question asked here is the one that matters - **will this object respond to these calls** -
|
|
26
|
+
and it is asked with ordinary attribute access, which honours every way Python has of providing an
|
|
27
|
+
attribute.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
__all__ = ["members_of", "satisfies"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def members_of(protocol: type) -> tuple[str, ...]:
|
|
38
|
+
"""The public members a protocol names: its methods and its annotated data attributes.
|
|
39
|
+
|
|
40
|
+
Both halves are needed and neither is enough. Methods have class attributes, so ``dir`` finds
|
|
41
|
+
them; an annotated attribute with no value - ``dialect: str`` - exists only in
|
|
42
|
+
``__annotations__``. Assembled from two public sources rather than from
|
|
43
|
+
``__protocol_attrs__``, which is an implementation detail that did not exist in every version
|
|
44
|
+
this library supports.
|
|
45
|
+
"""
|
|
46
|
+
named = {name for name in dir(protocol) if not name.startswith("_")}
|
|
47
|
+
named.update(
|
|
48
|
+
name
|
|
49
|
+
for name in getattr(protocol, "__annotations__", {})
|
|
50
|
+
if not name.startswith("_")
|
|
51
|
+
)
|
|
52
|
+
return tuple(sorted(named))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def satisfies(obj: Any, protocol: type) -> bool:
|
|
56
|
+
"""Whether every member the protocol names can be reached on this object.
|
|
57
|
+
|
|
58
|
+
Presence rather than callability, deliberately. A member that exists and is not callable fails
|
|
59
|
+
at the call with a message naming it, which is a better failure than a capability check that
|
|
60
|
+
quietly answers "no" and sends the reader to look at their engine.
|
|
61
|
+
"""
|
|
62
|
+
return all(hasattr(obj, name) for name in members_of(protocol))
|
sde/engines/__init__.py
ADDED
|
File without changes
|