python-corekit 0.1.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/__init__.py +0 -0
- corekit/api/__init__.py +9 -0
- corekit/api/handler.py +76 -0
- corekit/api/responses.py +40 -0
- corekit/api/routers.py +115 -0
- corekit/concurrency/__init__.py +9 -0
- corekit/concurrency/decorators.py +72 -0
- corekit/concurrency/thread_local.py +99 -0
- corekit/concurrency/worker.py +65 -0
- corekit/config/__init__.py +47 -0
- corekit/config/loader.py +153 -0
- corekit/config/settings.py +161 -0
- corekit/config/sources.py +125 -0
- corekit/connections/__init__.py +31 -0
- corekit/connections/connectable.py +212 -0
- corekit/connections/decorators.py +92 -0
- corekit/connections/redis/__init__.py +7 -0
- corekit/connections/redis/connection.py +239 -0
- corekit/connections/registry.py +80 -0
- corekit/connections/sql/__init__.py +10 -0
- corekit/connections/sql/connection.py +342 -0
- corekit/connections/sql/fields/__init__.py +7 -0
- corekit/connections/sql/fields/jsonb.py +67 -0
- corekit/connections/sql/migration/__init__.py +57 -0
- corekit/connections/sql/migration/base.py +40 -0
- corekit/connections/sql/migration/operations.py +416 -0
- corekit/connections/sql/migration/registry.py +166 -0
- corekit/connections/sql/migration/table.py +27 -0
- corekit/connections/sql/query.py +68 -0
- corekit/connections/sql/table.py +96 -0
- corekit/constants.py +45 -0
- corekit/crypto/__init__.py +1 -0
- corekit/crypto/constants.py +7 -0
- corekit/crypto/enum.py +11 -0
- corekit/crypto/hasher.py +89 -0
- corekit/data/__init__.py +81 -0
- corekit/data/dataset.py +340 -0
- corekit/data/expressions/__init__.py +46 -0
- corekit/data/expressions/comparison.py +252 -0
- corekit/data/expressions/expression.py +98 -0
- corekit/data/record.py +147 -0
- corekit/data/stats.py +157 -0
- corekit/decorators/__init__.py +2 -0
- corekit/decorators/exception_handling.py +43 -0
- corekit/decorators/warnings.py +35 -0
- corekit/docker/__init__.py +7 -0
- corekit/docker/watchdog.py +222 -0
- corekit/etl/__init__.py +44 -0
- corekit/etl/connection.py +44 -0
- corekit/etl/extract/__init__.py +0 -0
- corekit/etl/extract/extractor.py +48 -0
- corekit/etl/extract/schemas.py +18 -0
- corekit/etl/load/__init__.py +0 -0
- corekit/etl/load/loader.py +53 -0
- corekit/etl/load/schemas.py +33 -0
- corekit/etl/orchestrator.py +201 -0
- corekit/etl/schemas.py +22 -0
- corekit/etl/transform/__init__.py +0 -0
- corekit/etl/transform/schemas.py +15 -0
- corekit/etl/transform/transformer.py +28 -0
- corekit/events/__init__.py +38 -0
- corekit/events/enum.py +58 -0
- corekit/events/frames.py +51 -0
- corekit/events/models.py +23 -0
- corekit/events/publisher.py +75 -0
- corekit/events/reader.py +132 -0
- corekit/events/sse.py +109 -0
- corekit/events/websocket.py +97 -0
- corekit/exceptions/__init__.py +0 -0
- corekit/exceptions/base.py +45 -0
- corekit/exceptions/custom/__init__.py +0 -0
- corekit/exceptions/http/__init__.py +0 -0
- corekit/exceptions/http/exceptions.py +37 -0
- corekit/exceptions/types.py +17 -0
- corekit/files/__init__.py +25 -0
- corekit/files/base.py +117 -0
- corekit/files/enum.py +30 -0
- corekit/files/json.py +12 -0
- corekit/files/pickle.py +12 -0
- corekit/files/toml.py +43 -0
- corekit/http/__init__.py +0 -0
- corekit/http/client.py +176 -0
- corekit/http/exponential_backoff.py +100 -0
- corekit/http/response.py +12 -0
- corekit/log_monitor/__init__.py +23 -0
- corekit/log_monitor/constants.py +8 -0
- corekit/log_monitor/models.py +150 -0
- corekit/log_monitor/service.py +418 -0
- corekit/notifications/__init__.py +8 -0
- corekit/notifications/base.py +51 -0
- corekit/notifications/models.py +34 -0
- corekit/observability/__init__.py +21 -0
- corekit/observability/benchmarkable.py +12 -0
- corekit/observability/loggable.py +29 -0
- corekit/observability/timing/__init__.py +0 -0
- corekit/observability/timing/constants.py +1 -0
- corekit/observability/timing/split.py +20 -0
- corekit/observability/timing/timer.py +30 -0
- corekit/py.typed +0 -0
- corekit/registry/__init__.py +12 -0
- corekit/registry/registry.py +134 -0
- corekit/schemas/__init__.py +0 -0
- corekit/schemas/dataclasses/__init__.py +0 -0
- corekit/schemas/enum.py +49 -0
- corekit/schemas/models/__init__.py +0 -0
- corekit/schemas/models/arbitrary.py +11 -0
- corekit/schemas/models/date_models.py +18 -0
- corekit/schemas/pydantic/__init__.py +0 -0
- corekit/schemas/pydantic/fields.py +35 -0
- corekit/schemas/types.py +40 -0
- corekit/serialization/__init__.py +0 -0
- corekit/serialization/enum.py +21 -0
- corekit/serialization/serializable.py +42 -0
- corekit/serialization/serializer.py +179 -0
- corekit/utils/__init__.py +5 -0
- corekit/utils/ids.py +5 -0
- corekit/utils/raise_exc.py +8 -0
- corekit/utils/time.py +21 -0
- corekit/utils/validators.py +15 -0
- corekit/utils/void.py +8 -0
- python_corekit-0.1.0.dist-info/METADATA +417 -0
- python_corekit-0.1.0.dist-info/RECORD +125 -0
- python_corekit-0.1.0.dist-info/WHEEL +5 -0
- python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_corekit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discovering, verifying and running migrations.
|
|
3
|
+
|
|
4
|
+
On startup the registry checks that every previously-applied migration still
|
|
5
|
+
matches the checksum it was applied under, then runs whatever is pending in
|
|
6
|
+
version order.
|
|
7
|
+
|
|
8
|
+
The checksum is taken over each operation's ``canonical()`` -- a structural
|
|
9
|
+
description of what the operation does -- rather than over its rendered SQL.
|
|
10
|
+
Reformatting the generated SQL therefore leaves history valid, while changing
|
|
11
|
+
what a migration actually does invalidates it. A mismatch stops startup rather
|
|
12
|
+
than being repaired silently, because a migration that has already run cannot
|
|
13
|
+
be un-run by editing its source.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import inspect
|
|
18
|
+
import pkgutil
|
|
19
|
+
from types import ModuleType
|
|
20
|
+
|
|
21
|
+
from corekit.connections.sql.connection import SQLConnection
|
|
22
|
+
from corekit.connections.sql.migration.base import Migration
|
|
23
|
+
from corekit.connections.sql.migration.operations import DataOperation
|
|
24
|
+
from corekit.connections.sql.migration.table import SchemaMigration
|
|
25
|
+
from corekit.observability.benchmarkable import Benchmarkable
|
|
26
|
+
|
|
27
|
+
__all__ = ["MigrationChecksumError", "MigrationRegistry"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MigrationChecksumError(RuntimeError):
|
|
31
|
+
"""
|
|
32
|
+
Raised when an applied migration no longer matches its recorded checksum.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MigrationRegistry(Benchmarkable):
|
|
37
|
+
"""
|
|
38
|
+
An ordered, validated set of migrations.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *migrations: Migration) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._migrations = self._validate(list(migrations))
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def migrations(self) -> list[Migration]:
|
|
47
|
+
"""
|
|
48
|
+
The migrations, in version order.
|
|
49
|
+
"""
|
|
50
|
+
return list(self._migrations)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def discover(cls, package: ModuleType) -> "MigrationRegistry":
|
|
54
|
+
"""
|
|
55
|
+
Build a registry from every Migration subclass defined in a package.
|
|
56
|
+
|
|
57
|
+
Adding a file to that package is the whole registration step.
|
|
58
|
+
"""
|
|
59
|
+
migrations: list[Migration] = []
|
|
60
|
+
for module_info in pkgutil.iter_modules(package.__path__, prefix=f"{package.__name__}."):
|
|
61
|
+
module = importlib.import_module(module_info.name)
|
|
62
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
63
|
+
if obj is Migration or not issubclass(obj, Migration) or obj.__module__ != module.__name__:
|
|
64
|
+
continue
|
|
65
|
+
migrations.append(obj())
|
|
66
|
+
return cls(*migrations)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _validate(migrations: list[Migration]) -> list[Migration]:
|
|
70
|
+
"""
|
|
71
|
+
Reject duplicates and incomplete definitions, and sort by version.
|
|
72
|
+
"""
|
|
73
|
+
seen_versions: set[int] = set()
|
|
74
|
+
for migration in migrations:
|
|
75
|
+
# version and name are annotated on the base class but not
|
|
76
|
+
# assigned, so an undeclared one raises AttributeError rather than
|
|
77
|
+
# returning the default. Repr is avoided for the same reason.
|
|
78
|
+
version = getattr(migration, "version", None)
|
|
79
|
+
if version is None or getattr(migration, "name", None) is None:
|
|
80
|
+
raise ValueError(f"Migration {type(migration).__name__} is missing version or name.")
|
|
81
|
+
if version in seen_versions:
|
|
82
|
+
raise ValueError(f"Duplicate migration version: {migration.version}")
|
|
83
|
+
seen_versions.add(version)
|
|
84
|
+
return sorted(migrations, key=lambda m: m.version)
|
|
85
|
+
|
|
86
|
+
def _fetch_applied(self, conn: SQLConnection) -> dict[int, str]:
|
|
87
|
+
"""
|
|
88
|
+
Return ``{version: checksum}`` for everything already applied.
|
|
89
|
+
"""
|
|
90
|
+
return {row.version: row.checksum for row in conn.fetch_all(SchemaMigration)}
|
|
91
|
+
|
|
92
|
+
def _verify_checksums(self, applied: dict[int, str]) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Confirm no applied migration has been edited since it ran.
|
|
95
|
+
"""
|
|
96
|
+
for migration in self._migrations:
|
|
97
|
+
stored = applied.get(migration.version)
|
|
98
|
+
if stored is None or stored == migration.checksum:
|
|
99
|
+
continue
|
|
100
|
+
raise MigrationChecksumError(
|
|
101
|
+
f"\n\nMIGRATION CHECKSUM MISMATCH - REFUSING TO START\n"
|
|
102
|
+
f" Migration : v{migration.version} ({migration.name})\n"
|
|
103
|
+
f" Stored : {stored}\n"
|
|
104
|
+
f" Current : {migration.checksum}\n"
|
|
105
|
+
f"This migration was modified after it was applied to the database.\n"
|
|
106
|
+
f"Restore it to its original content, or add a new migration instead.\n"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def pending(self, conn: SQLConnection) -> list[Migration]:
|
|
110
|
+
"""
|
|
111
|
+
The migrations not yet applied to this database.
|
|
112
|
+
"""
|
|
113
|
+
applied = self._fetch_applied(conn)
|
|
114
|
+
return [m for m in self._migrations if m.version not in applied]
|
|
115
|
+
|
|
116
|
+
def _run_migration(self, conn: SQLConnection, migration: Migration) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Apply one migration and record it.
|
|
119
|
+
"""
|
|
120
|
+
self.info(f"Applying migration v{migration.version}: {migration.name}")
|
|
121
|
+
for operation in migration.operations():
|
|
122
|
+
self.info(f" {operation.canonical()}")
|
|
123
|
+
if isinstance(operation, DataOperation):
|
|
124
|
+
operation.execute(conn)
|
|
125
|
+
else:
|
|
126
|
+
conn.exec_ddl(operation.to_sql())
|
|
127
|
+
|
|
128
|
+
conn.insert(
|
|
129
|
+
SchemaMigration(
|
|
130
|
+
version=migration.version,
|
|
131
|
+
name=migration.name,
|
|
132
|
+
checksum=migration.checksum,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
self.info(f"Migration v{migration.version} applied.")
|
|
136
|
+
|
|
137
|
+
def run(self, conn: SQLConnection) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Verify history, then apply anything pending.
|
|
140
|
+
|
|
141
|
+
A failing migration stops the run: later migrations are written against
|
|
142
|
+
the schema earlier ones produce, so continuing past a failure would
|
|
143
|
+
apply them to a schema they were never written for.
|
|
144
|
+
|
|
145
|
+
Note that DDL is committed per statement, so a migration that fails
|
|
146
|
+
part-way leaves its earlier operations applied but the migration itself
|
|
147
|
+
unrecorded. Prefer several small migrations over one large one, and
|
|
148
|
+
check the schema before re-running after a failure.
|
|
149
|
+
"""
|
|
150
|
+
applied = self._fetch_applied(conn)
|
|
151
|
+
self._verify_checksums(applied)
|
|
152
|
+
|
|
153
|
+
pending = [m for m in self._migrations if m.version not in applied]
|
|
154
|
+
if not pending:
|
|
155
|
+
self.info("Database schema is up to date.")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self.info(f"{len(pending)} migration(s) pending.")
|
|
159
|
+
for migration in pending:
|
|
160
|
+
try:
|
|
161
|
+
self._run_migration(conn, migration)
|
|
162
|
+
except MigrationChecksumError:
|
|
163
|
+
raise
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
self.error(f"Migration v{migration.version} ({migration.name}) failed: {exc}")
|
|
166
|
+
raise
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The table recording which migrations have been applied.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from sqlmodel import Field, SQLModel
|
|
8
|
+
|
|
9
|
+
from corekit.utils.time import time_now
|
|
10
|
+
|
|
11
|
+
__all__ = ["SchemaMigration"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SchemaMigration(SQLModel, table=True):
|
|
15
|
+
"""
|
|
16
|
+
One row per applied migration, with the checksum it was applied under.
|
|
17
|
+
|
|
18
|
+
Inherits SQLModel rather than NamedTable: this table is keyed by version,
|
|
19
|
+
and has no string id for NamedTable's registry to address it by.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__tablename__ = "schema_migration"
|
|
23
|
+
|
|
24
|
+
version: int = Field(primary_key=True)
|
|
25
|
+
name: str
|
|
26
|
+
checksum: str
|
|
27
|
+
applied_at: datetime = Field(default_factory=time_now)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A chainable query builder over SQLModel's select().
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from sqlmodel import desc as _desc
|
|
9
|
+
from sqlmodel import select
|
|
10
|
+
|
|
11
|
+
from corekit.connections.sql.table import NamedTable
|
|
12
|
+
|
|
13
|
+
__all__ = ["Query"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Query(BaseModel):
|
|
17
|
+
"""
|
|
18
|
+
Accumulates filters, ordering and a limit, then builds a select statement.
|
|
19
|
+
|
|
20
|
+
Query(table=User).where(User.age >= 18).order_by(User.name).limit(10)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
table: type[NamedTable]
|
|
24
|
+
queries: list[Any] = Field(default_factory=list)
|
|
25
|
+
order_by_field: Any = None
|
|
26
|
+
order_by_desc: bool = False
|
|
27
|
+
limit_count: int = -1
|
|
28
|
+
|
|
29
|
+
def where(self, query: Any) -> "Query":
|
|
30
|
+
"""
|
|
31
|
+
Add a filter clause.
|
|
32
|
+
"""
|
|
33
|
+
self.queries.append(query)
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def order_by(self, field: Any, desc: bool = False) -> "Query":
|
|
37
|
+
"""
|
|
38
|
+
Order results by a field.
|
|
39
|
+
"""
|
|
40
|
+
self.order_by_field = field
|
|
41
|
+
self.order_by_desc = desc
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def limit(self, limit: int) -> "Query":
|
|
45
|
+
"""
|
|
46
|
+
Cap the number of rows returned. A non-positive value means no limit.
|
|
47
|
+
"""
|
|
48
|
+
self.limit_count = limit
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def build(self) -> Any:
|
|
52
|
+
"""
|
|
53
|
+
Produce the select statement.
|
|
54
|
+
"""
|
|
55
|
+
statement = select(self.table)
|
|
56
|
+
for query in self.queries:
|
|
57
|
+
statement = statement.where(query)
|
|
58
|
+
|
|
59
|
+
if self.order_by_field is not None:
|
|
60
|
+
order_by_field = self.order_by_field
|
|
61
|
+
if self.order_by_desc:
|
|
62
|
+
order_by_field = _desc(order_by_field)
|
|
63
|
+
statement = statement.order_by(order_by_field)
|
|
64
|
+
|
|
65
|
+
if self.limit_count > 0:
|
|
66
|
+
statement = statement.limit(self.limit_count)
|
|
67
|
+
|
|
68
|
+
return statement
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base table type.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlmodel import SQLModel
|
|
10
|
+
|
|
11
|
+
from corekit.registry import SmartRegistry
|
|
12
|
+
|
|
13
|
+
__all__ = ["NamedTable"]
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NamedTable(SQLModel):
|
|
19
|
+
"""
|
|
20
|
+
Base for tables that are addressable by a string id.
|
|
21
|
+
|
|
22
|
+
Subclasses register themselves by name, so a table class can be resolved
|
|
23
|
+
from a string -- useful for imports, exports and admin tooling that work
|
|
24
|
+
with table names rather than imported classes.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__registry__: SmartRegistry = SmartRegistry()
|
|
28
|
+
|
|
29
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Register every subclass under its class name.
|
|
32
|
+
"""
|
|
33
|
+
super().__init_subclass__(**kwargs)
|
|
34
|
+
NamedTable.__registry__[cls.__name__] = cls
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def get_table_types(cls) -> SmartRegistry:
|
|
38
|
+
"""
|
|
39
|
+
Return the registry of known table classes.
|
|
40
|
+
"""
|
|
41
|
+
return cls.__registry__
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def get_table_by_name(cls, name: str) -> type["NamedTable"] | None:
|
|
45
|
+
"""
|
|
46
|
+
Look up a table class by name, using the registry's normalization.
|
|
47
|
+
"""
|
|
48
|
+
return cls.__registry__.get(name)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def table_name(self) -> str:
|
|
52
|
+
"""
|
|
53
|
+
The name of the table this record belongs to.
|
|
54
|
+
"""
|
|
55
|
+
return type(self).__name__
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def get_display_name(cls) -> str:
|
|
59
|
+
"""
|
|
60
|
+
A human-readable name for the table.
|
|
61
|
+
"""
|
|
62
|
+
return getattr(cls, "__display_name__", None) or cls.__name__
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def create_default(cls, identifier: str, **kwargs: Any) -> "NamedTable":
|
|
66
|
+
"""
|
|
67
|
+
Build an instance carrying only its identifier and any supplied fields.
|
|
68
|
+
"""
|
|
69
|
+
kwargs["id"] = kwargs.get("id") or identifier
|
|
70
|
+
logger.info(f"Creating default {cls.__name__} with id {kwargs['id']!r}")
|
|
71
|
+
return cls(**kwargs)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _serialize(value: Any) -> str:
|
|
75
|
+
return json.dumps(value)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _deserialize(value: str) -> Any:
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(value)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
logger.error(f"Unable to deserialize value: {value!r}")
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def serialize(self, values: dict[str, Any], key: str) -> dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
Replace ``values[key]`` with its JSON encoding.
|
|
88
|
+
"""
|
|
89
|
+
values[key] = self._serialize(values.get(key))
|
|
90
|
+
return values
|
|
91
|
+
|
|
92
|
+
def deserialize(self, key: str) -> Any:
|
|
93
|
+
"""
|
|
94
|
+
Decode the JSON stored in the named field.
|
|
95
|
+
"""
|
|
96
|
+
return self._deserialize(getattr(self, key))
|
corekit/constants.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Minutes
|
|
2
|
+
ONE_MINUTE = 60
|
|
3
|
+
TWO_MINUTES = ONE_MINUTE * 2
|
|
4
|
+
THREE_MINUTES = ONE_MINUTE * 3
|
|
5
|
+
FIVE_MINUTES = ONE_MINUTE * 5
|
|
6
|
+
TEN_MINUTES = ONE_MINUTE * 10
|
|
7
|
+
FIFTEEN_MINUTES = ONE_MINUTE * 15
|
|
8
|
+
THIRTY_MINUTES = ONE_MINUTE * 30
|
|
9
|
+
FORTY_FIVE_MINUTES = ONE_MINUTE * 45
|
|
10
|
+
|
|
11
|
+
# Hours
|
|
12
|
+
ONE_HOUR = ONE_MINUTE * 60
|
|
13
|
+
TWO_HOURS = ONE_HOUR * 2
|
|
14
|
+
THREE_HOURS = ONE_HOUR * 3
|
|
15
|
+
FIVE_HOURS = ONE_HOUR * 5
|
|
16
|
+
SIX_HOURS = ONE_HOUR * 6
|
|
17
|
+
NINE_HOURS = ONE_HOUR * 9
|
|
18
|
+
TWELVE_HOURS = ONE_HOUR * 12
|
|
19
|
+
FIFTEEN_HOURS = ONE_HOUR * 15
|
|
20
|
+
EIGHTEEN_HOURS = ONE_HOUR * 18
|
|
21
|
+
|
|
22
|
+
# Days
|
|
23
|
+
ONE_DAY = ONE_HOUR * 24
|
|
24
|
+
TWO_DAYS = ONE_DAY * 2
|
|
25
|
+
THREE_DAYS = ONE_DAY * 3
|
|
26
|
+
FOUR_DAYS = ONE_DAY * 4
|
|
27
|
+
FIVE_DAYS = ONE_DAY * 5
|
|
28
|
+
SIX_DAYS = ONE_DAY * 6
|
|
29
|
+
TEN_DAYS = ONE_DAY * 10
|
|
30
|
+
FIFTEEN_DAYS = ONE_DAY * 15
|
|
31
|
+
THIRTY_DAYS = ONE_DAY * 30
|
|
32
|
+
FORTY_FIVE_DAYS = ONE_DAY * 45
|
|
33
|
+
SIXTY_FIVE_DAYS = ONE_DAY * 60
|
|
34
|
+
NINETY_FIVE_DAYS = ONE_DAY * 90
|
|
35
|
+
ONE_HUNDRED_TWENTY_DAYS = ONE_DAY * 120
|
|
36
|
+
ONE_HUNDRED_EIGHTY_DAYS = ONE_DAY * 180
|
|
37
|
+
|
|
38
|
+
# Weeks
|
|
39
|
+
ONE_WEEK = ONE_DAY * 7
|
|
40
|
+
TWO_WEEKS = ONE_WEEK * 2
|
|
41
|
+
THREE_WEEKS = ONE_WEEK * 3
|
|
42
|
+
FOUR_WEEKS = ONE_WEEK * 4
|
|
43
|
+
|
|
44
|
+
# Years
|
|
45
|
+
ONE_YEAR = ONE_DAY * 365
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .hasher import Hasher
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
HASH_JOINER = ":"
|
|
2
|
+
EMPTY_HASH = ""
|
|
3
|
+
|
|
4
|
+
# NOTE: a hardcoded CRYPTO_SALT previously lived here and is still present in this
|
|
5
|
+
# repository's git history. It must be considered compromised and must never be
|
|
6
|
+
# reintroduced. The salt is now supplied through configuration -- see
|
|
7
|
+
# `corekit.config.CorekitSettings.crypto_salt` and `corekit.crypto.hasher.Hasher`.
|
corekit/crypto/enum.py
ADDED
corekit/crypto/hasher.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from corekit.config import get_settings
|
|
5
|
+
from corekit.crypto.constants import EMPTY_HASH, HASH_JOINER
|
|
6
|
+
from corekit.crypto.enum import SaltMethod
|
|
7
|
+
from corekit.observability.loggable import Loggable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissingSaltError(RuntimeError):
|
|
11
|
+
"""
|
|
12
|
+
Raised when hashing is attempted without a configured salt.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Hasher(Loggable):
|
|
17
|
+
"""
|
|
18
|
+
Salted SHA-256 hashing.
|
|
19
|
+
|
|
20
|
+
The salt has no default. Supply it explicitly, or configure it via
|
|
21
|
+
``COREKIT_CRYPTO_SALT`` / ``crypto_salt`` in a corekit config file. Resolution
|
|
22
|
+
is deferred until first use so that importing this module never fails.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, joiner: str = HASH_JOINER, salt: str | None = None, salt_method: SaltMethod = SaltMethod.get_default()
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._joiner = joiner
|
|
30
|
+
self._explicit_salt = salt
|
|
31
|
+
self._salt_method = salt_method
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def _salt(self) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Resolve the salt, preferring the explicit argument over configuration.
|
|
37
|
+
"""
|
|
38
|
+
salt = self._explicit_salt
|
|
39
|
+
if salt is None:
|
|
40
|
+
salt = get_settings().crypto.salt
|
|
41
|
+
if not salt:
|
|
42
|
+
raise MissingSaltError(
|
|
43
|
+
"No crypto salt configured. Pass salt=... to Hasher, set "
|
|
44
|
+
"COREKIT_CRYPTO_SALT, or add crypto_salt to your corekit config. "
|
|
45
|
+
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
|
46
|
+
)
|
|
47
|
+
return salt
|
|
48
|
+
|
|
49
|
+
def salt(self, *args: Any) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Given any number of inputs, combine and salt the input in preparation for hashing.
|
|
52
|
+
"""
|
|
53
|
+
if len(args) < 1:
|
|
54
|
+
self.warning("No input to hash. Returning empty hash")
|
|
55
|
+
return EMPTY_HASH
|
|
56
|
+
|
|
57
|
+
salted: str = ""
|
|
58
|
+
# Add the prefix for all methods other than POST
|
|
59
|
+
if self._salt_method != SaltMethod.POST:
|
|
60
|
+
salted += f"{self._salt}{self._joiner}"
|
|
61
|
+
|
|
62
|
+
salted += self._joiner.join(str(arg) for arg in args)
|
|
63
|
+
|
|
64
|
+
# Add the suffix for all methods other than PRE
|
|
65
|
+
if self._salt_method != SaltMethod.PRE:
|
|
66
|
+
salted += f"{self._joiner}{self._salt}"
|
|
67
|
+
|
|
68
|
+
return salted
|
|
69
|
+
|
|
70
|
+
def hash(self, *args: Any) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Salt the inputs and return their SHA-256 digest.
|
|
73
|
+
|
|
74
|
+
With no input this returns EMPTY_HASH rather than the digest of the
|
|
75
|
+
empty string, so "nothing was hashed" stays distinguishable from a real
|
|
76
|
+
digest -- which is what salt() already reports and what EMPTY_HASH is
|
|
77
|
+
for.
|
|
78
|
+
"""
|
|
79
|
+
to_hash = self.salt(*args)
|
|
80
|
+
if to_hash == EMPTY_HASH:
|
|
81
|
+
return EMPTY_HASH
|
|
82
|
+
return hashlib.sha256(to_hash.encode(self.encoding)).hexdigest()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def encoding(self) -> str:
|
|
86
|
+
"""
|
|
87
|
+
The encoding used to turn salted text into bytes.
|
|
88
|
+
"""
|
|
89
|
+
return "utf-8"
|
corekit/data/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory datasets with composable filtering.
|
|
3
|
+
|
|
4
|
+
``Dataset`` holds ``__slots__``-based records sharing one schema, with O(1)
|
|
5
|
+
lookup by id, in-place editing, schema evolution and clean pickling. Filtering
|
|
6
|
+
uses expressions rather than callables::
|
|
7
|
+
|
|
8
|
+
from corekit.data import Dataset, Field
|
|
9
|
+
|
|
10
|
+
people = Dataset(id_key="name", schema=["name", "age"])
|
|
11
|
+
people.add({"name": "Ada", "age": 36})
|
|
12
|
+
|
|
13
|
+
adults = people.filter(Field("age") >= 18)
|
|
14
|
+
people.get_record("Ada").age
|
|
15
|
+
|
|
16
|
+
``Field`` is an alias of ``FieldExpression``: ``Field("a") > Field("b")``
|
|
17
|
+
compares two fields, while ``Field("a") > 3`` compares against a value.
|
|
18
|
+
|
|
19
|
+
Depends only on the standard library.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from corekit.data.dataset import Dataset
|
|
23
|
+
from corekit.data.expressions import (
|
|
24
|
+
And,
|
|
25
|
+
Comparison,
|
|
26
|
+
Contains,
|
|
27
|
+
Equals,
|
|
28
|
+
Expression,
|
|
29
|
+
FieldExpression,
|
|
30
|
+
GreaterThan,
|
|
31
|
+
GreaterThanOrEquals,
|
|
32
|
+
IsIn,
|
|
33
|
+
LessThan,
|
|
34
|
+
LessThanOrEquals,
|
|
35
|
+
Not,
|
|
36
|
+
NotEquals,
|
|
37
|
+
Or,
|
|
38
|
+
ValueExpression,
|
|
39
|
+
)
|
|
40
|
+
from corekit.data.record import BaseRecord, is_valid_key, resolve_default
|
|
41
|
+
from corekit.data.stats import (
|
|
42
|
+
CategoricalFieldStats,
|
|
43
|
+
DatasetStats,
|
|
44
|
+
FieldDescription,
|
|
45
|
+
FieldStats,
|
|
46
|
+
NumericFieldStats,
|
|
47
|
+
ValueCounts,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# The expression used to name a field. Field("age") reads better than
|
|
51
|
+
# FieldExpression("age") at a call site.
|
|
52
|
+
Field = FieldExpression
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"And",
|
|
56
|
+
"BaseRecord",
|
|
57
|
+
"CategoricalFieldStats",
|
|
58
|
+
"Comparison",
|
|
59
|
+
"Contains",
|
|
60
|
+
"Dataset",
|
|
61
|
+
"DatasetStats",
|
|
62
|
+
"Equals",
|
|
63
|
+
"Expression",
|
|
64
|
+
"Field",
|
|
65
|
+
"FieldDescription",
|
|
66
|
+
"FieldExpression",
|
|
67
|
+
"FieldStats",
|
|
68
|
+
"GreaterThan",
|
|
69
|
+
"GreaterThanOrEquals",
|
|
70
|
+
"IsIn",
|
|
71
|
+
"LessThan",
|
|
72
|
+
"LessThanOrEquals",
|
|
73
|
+
"Not",
|
|
74
|
+
"NotEquals",
|
|
75
|
+
"NumericFieldStats",
|
|
76
|
+
"Or",
|
|
77
|
+
"ValueCounts",
|
|
78
|
+
"ValueExpression",
|
|
79
|
+
"is_valid_key",
|
|
80
|
+
"resolve_default",
|
|
81
|
+
]
|