python-corekit 0.1.0__py3-none-any.whl → 0.2.0__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.
- corekit/api/__init__.py +18 -3
- corekit/api/application.py +237 -0
- corekit/api/lifespan.py +210 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/worker.py +65 -65
- corekit/config/settings.py +3 -3
- corekit/connections/sql/__init__.py +31 -3
- corekit/connections/sql/connection.py +19 -0
- corekit/connections/sql/migration/__init__.py +5 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +66 -42
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +102 -0
- corekit/connections/sql/operations/statements.py +150 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +30 -4
- corekit/constants.py +45 -45
- corekit/crypto/constants.py +4 -4
- corekit/data/__init__.py +8 -0
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +184 -104
- corekit/data/expressions/expression.py +103 -98
- corekit/data/expressions/operator.py +54 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +159 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +2 -1
- corekit/etl/connection.py +44 -44
- corekit/events/websocket.py +3 -2
- corekit/exceptions/__init__.py +18 -0
- corekit/http/__init__.py +13 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +69 -0
- corekit/jobs/task.py +152 -0
- corekit/observability/__init__.py +5 -3
- corekit/observability/request_context.py +135 -0
- corekit/registry/__init__.py +11 -6
- corekit/registry/ordered.py +86 -0
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +49 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +40 -40
- corekit/serialization/__init__.py +22 -0
- corekit/serialization/serializer.py +1 -1
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +115 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +100 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/METADATA +105 -100
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/RECORD +64 -46
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,98 +1,103 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Expression:
|
|
5
|
-
"""
|
|
6
|
-
Base class for composable predicates. Subclasses implement __call__
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
10
|
-
raise NotImplementedError
|
|
11
|
-
|
|
12
|
-
def __and__(self, other: "Expression") -> "Expression":
|
|
13
|
-
return And(self, other)
|
|
14
|
-
|
|
15
|
-
def __or__(self, other: "Expression") -> "Expression":
|
|
16
|
-
return Or(self, other)
|
|
17
|
-
|
|
18
|
-
def __invert__(self) -> "Expression":
|
|
19
|
-
return Not(self)
|
|
20
|
-
|
|
21
|
-
def
|
|
22
|
-
raise NotImplementedError
|
|
23
|
-
|
|
24
|
-
def
|
|
25
|
-
raise NotImplementedError
|
|
26
|
-
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def
|
|
58
|
-
self.left
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def
|
|
83
|
-
self.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Expression:
|
|
5
|
+
"""
|
|
6
|
+
Base class for composable predicates. Subclasses implement __call__
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
def __and__(self, other: "Expression") -> "Expression":
|
|
13
|
+
return And(self, other)
|
|
14
|
+
|
|
15
|
+
def __or__(self, other: "Expression") -> "Expression":
|
|
16
|
+
return Or(self, other)
|
|
17
|
+
|
|
18
|
+
def __invert__(self) -> "Expression":
|
|
19
|
+
return Not(self)
|
|
20
|
+
|
|
21
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
def to_sqlalchemy(self, context: Any) -> Any:
|
|
28
|
+
"""
|
|
29
|
+
Compile to a SQLAlchemy clause against ``context``, a model class.
|
|
30
|
+
|
|
31
|
+
Values are bound as parameters rather than rendered into SQL text.
|
|
32
|
+
"""
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class And(Expression):
|
|
37
|
+
def __init__(self, left: Expression, right: Expression) -> None:
|
|
38
|
+
self.left = left
|
|
39
|
+
self.right = right
|
|
40
|
+
|
|
41
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
42
|
+
return bool(self.left(*args, **kwargs)) and bool(self.right(*args, **kwargs))
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return f"({self.left!r} & {self.right!r})"
|
|
46
|
+
|
|
47
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
48
|
+
return {"$and": [self.left.to_mongo(*args, **kwargs), self.right.to_mongo(*args, **kwargs)]}
|
|
49
|
+
|
|
50
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
51
|
+
return {
|
|
52
|
+
"bool": {
|
|
53
|
+
"must": [self.left.to_elasticsearch(*args, **kwargs), self.right.to_elasticsearch(*args, **kwargs)]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def to_sqlalchemy(self, context: Any) -> Any:
|
|
58
|
+
return self.left.to_sqlalchemy(context) & self.right.to_sqlalchemy(context)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Or(Expression):
|
|
62
|
+
def __init__(self, left: Expression, right: Expression) -> None:
|
|
63
|
+
self.left = left
|
|
64
|
+
self.right = right
|
|
65
|
+
|
|
66
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
67
|
+
return bool(self.left(*args, **kwargs)) or bool(self.right(*args, **kwargs))
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return f"({self.left!r} | {self.right!r})"
|
|
71
|
+
|
|
72
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
73
|
+
return {"$or": [self.left.to_mongo(*args, **kwargs), self.right.to_mongo(*args, **kwargs)]}
|
|
74
|
+
|
|
75
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
76
|
+
return {
|
|
77
|
+
"bool": {
|
|
78
|
+
"should": [self.left.to_elasticsearch(*args, **kwargs), self.right.to_elasticsearch(*args, **kwargs)]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def to_sqlalchemy(self, context: Any) -> Any:
|
|
83
|
+
return self.left.to_sqlalchemy(context) | self.right.to_sqlalchemy(context)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Not(Expression):
|
|
87
|
+
def __init__(self, expr: Expression) -> None:
|
|
88
|
+
self.expr = expr
|
|
89
|
+
|
|
90
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
91
|
+
return not self.expr(*args, **kwargs)
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return f"~{self.expr!r}"
|
|
95
|
+
|
|
96
|
+
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
97
|
+
return {"$not": self.expr.to_mongo(*args, **kwargs)}
|
|
98
|
+
|
|
99
|
+
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
100
|
+
return {"bool": {"must_not": self.expr.to_elasticsearch(*args, **kwargs)}}
|
|
101
|
+
|
|
102
|
+
def to_sqlalchemy(self, context: Any) -> Any:
|
|
103
|
+
return ~self.expr.to_sqlalchemy(context)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The operators a comparison can express, and how each dialect spells them.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from corekit.schemas.enum import ValidatingEnum
|
|
8
|
+
|
|
9
|
+
__all__ = ["Dialect", "Operator"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Dialect(NamedTuple):
|
|
13
|
+
"""
|
|
14
|
+
How one operator is spelled in each backend.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
symbol: The human-readable operator, used in ``repr``.
|
|
18
|
+
mongo: The MongoDB query operator.
|
|
19
|
+
elasticsearch: The Elasticsearch clause the operator renders into.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
symbol: str
|
|
23
|
+
mongo: str
|
|
24
|
+
elasticsearch: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Operator(ValidatingEnum):
|
|
28
|
+
"""
|
|
29
|
+
A comparison operator and its spelling in every dialect corekit renders to.
|
|
30
|
+
|
|
31
|
+
Adding a backend means adding a field to ``Dialect``, not a method to
|
|
32
|
+
every comparison class.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
EQUALS = Dialect("==", "$eq", "term")
|
|
36
|
+
NOT_EQUALS = Dialect("!=", "$ne", "term")
|
|
37
|
+
LESS_THAN = Dialect("<", "$lt", "range")
|
|
38
|
+
LESS_THAN_OR_EQUALS = Dialect("<=", "$lte", "range")
|
|
39
|
+
GREATER_THAN = Dialect(">", "$gt", "range")
|
|
40
|
+
GREATER_THAN_OR_EQUALS = Dialect(">=", "$gte", "range")
|
|
41
|
+
IS_IN = Dialect("is in", "$in", "terms")
|
|
42
|
+
CONTAINS = Dialect("contains", "$regex", "wildcard")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def symbol(self) -> str:
|
|
46
|
+
return self.value.symbol
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def mongo(self) -> str:
|
|
50
|
+
return self.value.mongo
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def elasticsearch(self) -> str:
|
|
54
|
+
return self.value.elasticsearch
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
How an operand should be resolved.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.schemas.enum import StringEnum
|
|
6
|
+
|
|
7
|
+
__all__ = ["Target"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Target(StringEnum):
|
|
11
|
+
"""
|
|
12
|
+
What a translator needs an operand resolved into.
|
|
13
|
+
|
|
14
|
+
Only ``FieldExpression`` reads this: a literal is its own value everywhere.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
#: The field's value, read out of the context being resolved against.
|
|
18
|
+
VALUE = "value"
|
|
19
|
+
|
|
20
|
+
#: The field's name, for a query that names fields as strings.
|
|
21
|
+
NAME = "name"
|
corekit/data/record.py
CHANGED
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
import functools
|
|
2
|
-
import keyword
|
|
3
|
-
import logging
|
|
4
|
-
from typing import Any, Callable, Iterator
|
|
5
|
-
|
|
6
|
-
logger = logging.getLogger(__name__)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def is_valid_key(key: Any) -> bool:
|
|
10
|
-
return isinstance(key, str) and key.isidentifier() and not keyword.iskeyword(key)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def return_constant(value: Any) -> Any:
|
|
14
|
-
return value
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def resolve_default(default: Any, default_factory: Callable[[], Any] | None) -> Callable[[], Any]:
|
|
18
|
-
"""
|
|
19
|
-
Normalize (default, default_factory) into a single zero-arg factory.
|
|
20
|
-
|
|
21
|
-
Raises on a raw mutable default (list/dict/set/bytearray), since that
|
|
22
|
-
object would otherwise be shared by reference across every record;
|
|
23
|
-
use default_factory instead. The non-factory path returns
|
|
24
|
-
functools.partial(_return_constant, default) rather than a lambda,
|
|
25
|
-
since a local lambda can't be pickled.
|
|
26
|
-
"""
|
|
27
|
-
if default_factory is not None:
|
|
28
|
-
if default is not None:
|
|
29
|
-
logger.warning("Both default and default_factory were provided. Prioritizing default_factory.")
|
|
30
|
-
return default_factory
|
|
31
|
-
|
|
32
|
-
if isinstance(default, (list, dict, set, bytearray)):
|
|
33
|
-
raise TypeError(
|
|
34
|
-
f"mutable default {default!r} would be shared by reference across every "
|
|
35
|
-
f"record; use default_factory instead (e.g. default_factory=list)"
|
|
36
|
-
)
|
|
37
|
-
return functools.partial(return_constant, default)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def reconstruct_record(fields: tuple, values: tuple) -> "BaseRecord":
|
|
41
|
-
"""
|
|
42
|
-
Rebuild a record on unpickling. Module-level so pickle can reference
|
|
43
|
-
it by import path -- the record's actual class is generated
|
|
44
|
-
dynamically and has no module path of its own.
|
|
45
|
-
"""
|
|
46
|
-
cls = BaseRecord.create_new(fields)
|
|
47
|
-
instance = cls()
|
|
48
|
-
for key, value in zip(fields, values):
|
|
49
|
-
instance[key] = value
|
|
50
|
-
return instance
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class BaseRecord:
|
|
54
|
-
"""
|
|
55
|
-
Base class for dynamically generated, schema-specific record types.
|
|
56
|
-
|
|
57
|
-
Subclasses are built per-dataset by create_new() with __slots__ for
|
|
58
|
-
each field, so instances carry no per-record __dict__ overhead.
|
|
59
|
-
__slots__ = () here means this base itself adds nothing on top of
|
|
60
|
-
that.
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
__slots__ = ()
|
|
64
|
-
__fieldmap__ = {}
|
|
65
|
-
|
|
66
|
-
@staticmethod
|
|
67
|
-
def create_new(fields: tuple) -> type:
|
|
68
|
-
"""
|
|
69
|
-
Build a record class for the given field names.
|
|
70
|
-
|
|
71
|
-
Fields that aren't valid Python identifiers get a synthetic slot
|
|
72
|
-
name (_slot_0, _slot_1, ...) instead, tracked via __fieldmap__
|
|
73
|
-
so dict-style access (record["weird field"]) still works.
|
|
74
|
-
Synthetic names are checked against real field names so a field
|
|
75
|
-
literally named "_slot_0" can't collide with one.
|
|
76
|
-
"""
|
|
77
|
-
fieldmap: dict[Any, str] = {}
|
|
78
|
-
slot_names: list[str] = []
|
|
79
|
-
reserved = {key for key in fields if is_valid_key(key)}
|
|
80
|
-
used: set[str] = set()
|
|
81
|
-
counter = 0
|
|
82
|
-
for key in fields:
|
|
83
|
-
if is_valid_key(key):
|
|
84
|
-
slot = key
|
|
85
|
-
else:
|
|
86
|
-
candidate = f"_slot_{counter}"
|
|
87
|
-
counter += 1
|
|
88
|
-
while candidate in reserved or candidate in used:
|
|
89
|
-
candidate = f"_slot_{counter}"
|
|
90
|
-
counter += 1
|
|
91
|
-
slot = candidate
|
|
92
|
-
used.add(slot)
|
|
93
|
-
fieldmap[key] = slot
|
|
94
|
-
slot_names.append(slot)
|
|
95
|
-
return type("Record", (BaseRecord,), {"__slots__": tuple(slot_names), "__fieldmap__": fieldmap})
|
|
96
|
-
|
|
97
|
-
def __getitem__(self, key: Any) -> Any:
|
|
98
|
-
slot = self.__fieldmap__.get(key, key)
|
|
99
|
-
try:
|
|
100
|
-
return getattr(self, slot)
|
|
101
|
-
except AttributeError:
|
|
102
|
-
raise KeyError(key) from None
|
|
103
|
-
|
|
104
|
-
def __setitem__(self, key: Any, value: Any) -> Any:
|
|
105
|
-
slot = self.__fieldmap__.get(key)
|
|
106
|
-
if slot is None:
|
|
107
|
-
raise KeyError(f"{key!r} does not exist for record: {self}")
|
|
108
|
-
setattr(self, slot, value)
|
|
109
|
-
|
|
110
|
-
def __contains__(self, key: Any) -> bool:
|
|
111
|
-
return key in self.__fieldmap__
|
|
112
|
-
|
|
113
|
-
def __iter__(self) -> Iterator[Any]:
|
|
114
|
-
return iter(self.__fieldmap__)
|
|
115
|
-
|
|
116
|
-
def __len__(self) -> int:
|
|
117
|
-
return len(self.__fieldmap__)
|
|
118
|
-
|
|
119
|
-
def __eq__(self, other: Any) -> bool:
|
|
120
|
-
"""
|
|
121
|
-
Value equality: same fields and values, not same object.
|
|
122
|
-
|
|
123
|
-
Records are mutable, so __hash__ is implicitly disabled once
|
|
124
|
-
__eq__ is defined (Python does this automatically) -- that's
|
|
125
|
-
intentional, not an oversight, since a hashable object whose
|
|
126
|
-
hash can change under mutation is unsafe to use as a dict/set key.
|
|
127
|
-
"""
|
|
128
|
-
if isinstance(other, BaseRecord):
|
|
129
|
-
return self.to_dict() == other.to_dict()
|
|
130
|
-
if isinstance(other, dict):
|
|
131
|
-
return self.to_dict() == other
|
|
132
|
-
return NotImplemented
|
|
133
|
-
|
|
134
|
-
def __repr__(self) -> str:
|
|
135
|
-
fields = ", ".join(f"{key}={self[key]!r}" for key in self.__fieldmap__)
|
|
136
|
-
return f"{type(self).__name__}({fields})"
|
|
137
|
-
|
|
138
|
-
def __reduce__(self) -> tuple:
|
|
139
|
-
"""
|
|
140
|
-
Pickle as (reconstruct_fn, (fields, values)); see _reconstruct_record
|
|
141
|
-
"""
|
|
142
|
-
fields = tuple(self.__fieldmap__.keys())
|
|
143
|
-
values = tuple(self[k] for k in fields)
|
|
144
|
-
return reconstruct_record, (fields, values)
|
|
145
|
-
|
|
146
|
-
def to_dict(self) -> dict[Any, Any]:
|
|
147
|
-
return {k: self[k] for k in self.__fieldmap__}
|
|
1
|
+
import functools
|
|
2
|
+
import keyword
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Callable, Iterator
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_valid_key(key: Any) -> bool:
|
|
10
|
+
return isinstance(key, str) and key.isidentifier() and not keyword.iskeyword(key)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def return_constant(value: Any) -> Any:
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_default(default: Any, default_factory: Callable[[], Any] | None) -> Callable[[], Any]:
|
|
18
|
+
"""
|
|
19
|
+
Normalize (default, default_factory) into a single zero-arg factory.
|
|
20
|
+
|
|
21
|
+
Raises on a raw mutable default (list/dict/set/bytearray), since that
|
|
22
|
+
object would otherwise be shared by reference across every record;
|
|
23
|
+
use default_factory instead. The non-factory path returns
|
|
24
|
+
functools.partial(_return_constant, default) rather than a lambda,
|
|
25
|
+
since a local lambda can't be pickled.
|
|
26
|
+
"""
|
|
27
|
+
if default_factory is not None:
|
|
28
|
+
if default is not None:
|
|
29
|
+
logger.warning("Both default and default_factory were provided. Prioritizing default_factory.")
|
|
30
|
+
return default_factory
|
|
31
|
+
|
|
32
|
+
if isinstance(default, (list, dict, set, bytearray)):
|
|
33
|
+
raise TypeError(
|
|
34
|
+
f"mutable default {default!r} would be shared by reference across every "
|
|
35
|
+
f"record; use default_factory instead (e.g. default_factory=list)"
|
|
36
|
+
)
|
|
37
|
+
return functools.partial(return_constant, default)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reconstruct_record(fields: tuple, values: tuple) -> "BaseRecord":
|
|
41
|
+
"""
|
|
42
|
+
Rebuild a record on unpickling. Module-level so pickle can reference
|
|
43
|
+
it by import path -- the record's actual class is generated
|
|
44
|
+
dynamically and has no module path of its own.
|
|
45
|
+
"""
|
|
46
|
+
cls = BaseRecord.create_new(fields)
|
|
47
|
+
instance = cls()
|
|
48
|
+
for key, value in zip(fields, values):
|
|
49
|
+
instance[key] = value
|
|
50
|
+
return instance
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BaseRecord:
|
|
54
|
+
"""
|
|
55
|
+
Base class for dynamically generated, schema-specific record types.
|
|
56
|
+
|
|
57
|
+
Subclasses are built per-dataset by create_new() with __slots__ for
|
|
58
|
+
each field, so instances carry no per-record __dict__ overhead.
|
|
59
|
+
__slots__ = () here means this base itself adds nothing on top of
|
|
60
|
+
that.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
__slots__ = ()
|
|
64
|
+
__fieldmap__ = {}
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def create_new(fields: tuple) -> type:
|
|
68
|
+
"""
|
|
69
|
+
Build a record class for the given field names.
|
|
70
|
+
|
|
71
|
+
Fields that aren't valid Python identifiers get a synthetic slot
|
|
72
|
+
name (_slot_0, _slot_1, ...) instead, tracked via __fieldmap__
|
|
73
|
+
so dict-style access (record["weird field"]) still works.
|
|
74
|
+
Synthetic names are checked against real field names so a field
|
|
75
|
+
literally named "_slot_0" can't collide with one.
|
|
76
|
+
"""
|
|
77
|
+
fieldmap: dict[Any, str] = {}
|
|
78
|
+
slot_names: list[str] = []
|
|
79
|
+
reserved = {key for key in fields if is_valid_key(key)}
|
|
80
|
+
used: set[str] = set()
|
|
81
|
+
counter = 0
|
|
82
|
+
for key in fields:
|
|
83
|
+
if is_valid_key(key):
|
|
84
|
+
slot = key
|
|
85
|
+
else:
|
|
86
|
+
candidate = f"_slot_{counter}"
|
|
87
|
+
counter += 1
|
|
88
|
+
while candidate in reserved or candidate in used:
|
|
89
|
+
candidate = f"_slot_{counter}"
|
|
90
|
+
counter += 1
|
|
91
|
+
slot = candidate
|
|
92
|
+
used.add(slot)
|
|
93
|
+
fieldmap[key] = slot
|
|
94
|
+
slot_names.append(slot)
|
|
95
|
+
return type("Record", (BaseRecord,), {"__slots__": tuple(slot_names), "__fieldmap__": fieldmap})
|
|
96
|
+
|
|
97
|
+
def __getitem__(self, key: Any) -> Any:
|
|
98
|
+
slot = self.__fieldmap__.get(key, key)
|
|
99
|
+
try:
|
|
100
|
+
return getattr(self, slot)
|
|
101
|
+
except AttributeError:
|
|
102
|
+
raise KeyError(key) from None
|
|
103
|
+
|
|
104
|
+
def __setitem__(self, key: Any, value: Any) -> Any:
|
|
105
|
+
slot = self.__fieldmap__.get(key)
|
|
106
|
+
if slot is None:
|
|
107
|
+
raise KeyError(f"{key!r} does not exist for record: {self}")
|
|
108
|
+
setattr(self, slot, value)
|
|
109
|
+
|
|
110
|
+
def __contains__(self, key: Any) -> bool:
|
|
111
|
+
return key in self.__fieldmap__
|
|
112
|
+
|
|
113
|
+
def __iter__(self) -> Iterator[Any]:
|
|
114
|
+
return iter(self.__fieldmap__)
|
|
115
|
+
|
|
116
|
+
def __len__(self) -> int:
|
|
117
|
+
return len(self.__fieldmap__)
|
|
118
|
+
|
|
119
|
+
def __eq__(self, other: Any) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Value equality: same fields and values, not same object.
|
|
122
|
+
|
|
123
|
+
Records are mutable, so __hash__ is implicitly disabled once
|
|
124
|
+
__eq__ is defined (Python does this automatically) -- that's
|
|
125
|
+
intentional, not an oversight, since a hashable object whose
|
|
126
|
+
hash can change under mutation is unsafe to use as a dict/set key.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(other, BaseRecord):
|
|
129
|
+
return self.to_dict() == other.to_dict()
|
|
130
|
+
if isinstance(other, dict):
|
|
131
|
+
return self.to_dict() == other
|
|
132
|
+
return NotImplemented
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str:
|
|
135
|
+
fields = ", ".join(f"{key}={self[key]!r}" for key in self.__fieldmap__)
|
|
136
|
+
return f"{type(self).__name__}({fields})"
|
|
137
|
+
|
|
138
|
+
def __reduce__(self) -> tuple:
|
|
139
|
+
"""
|
|
140
|
+
Pickle as (reconstruct_fn, (fields, values)); see _reconstruct_record
|
|
141
|
+
"""
|
|
142
|
+
fields = tuple(self.__fieldmap__.keys())
|
|
143
|
+
values = tuple(self[k] for k in fields)
|
|
144
|
+
return reconstruct_record, (fields, values)
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> dict[Any, Any]:
|
|
147
|
+
return {k: self[k] for k in self.__fieldmap__}
|