python-corekit 0.2.0__py3-none-any.whl → 0.3.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.
Files changed (84) hide show
  1. corekit/api/application.py +47 -9
  2. corekit/api/lifespan.py +26 -3
  3. corekit/concurrency/__init__.py +2 -2
  4. corekit/concurrency/decorators.py +32 -5
  5. corekit/concurrency/thread_local.py +2 -2
  6. corekit/concurrency/worker.py +9 -0
  7. corekit/config/loader.py +42 -5
  8. corekit/config/settings.py +11 -1
  9. corekit/connections/__init__.py +7 -1
  10. corekit/connections/connectable.py +45 -4
  11. corekit/connections/redis/connection.py +53 -10
  12. corekit/connections/sql/__init__.py +2 -1
  13. corekit/connections/sql/connection.py +39 -5
  14. corekit/connections/sql/fields/__init__.py +2 -2
  15. corekit/connections/sql/fields/jsonb.py +13 -6
  16. corekit/connections/sql/migration/__init__.py +4 -0
  17. corekit/connections/sql/migration/operations.py +69 -2
  18. corekit/connections/sql/operations/base.py +11 -2
  19. corekit/connections/sql/operations/statements.py +25 -5
  20. corekit/connections/sql/table.py +7 -29
  21. corekit/crypto/__init__.py +3 -1
  22. corekit/crypto/constants.py +2 -2
  23. corekit/crypto/hasher.py +9 -4
  24. corekit/data/dataset.py +8 -2
  25. corekit/data/expressions/__init__.py +3 -3
  26. corekit/data/expressions/comparison.py +19 -80
  27. corekit/data/expressions/expression.py +0 -32
  28. corekit/data/expressions/operator.py +13 -28
  29. corekit/data/stats.py +3 -0
  30. corekit/decorators/exception_handling.py +36 -8
  31. corekit/docker/watchdog.py +50 -31
  32. corekit/etl/__init__.py +2 -1
  33. corekit/etl/connection.py +14 -12
  34. corekit/etl/extract/extractor.py +6 -13
  35. corekit/etl/orchestrator.py +19 -2
  36. corekit/etl/schemas.py +2 -2
  37. corekit/etl/transform/transformer.py +4 -1
  38. corekit/events/publisher.py +1 -1
  39. corekit/events/reader.py +26 -21
  40. corekit/events/sse.py +4 -1
  41. corekit/events/websocket.py +24 -11
  42. corekit/exceptions/__init__.py +24 -9
  43. corekit/exceptions/base.py +139 -10
  44. corekit/exceptions/enum.py +17 -0
  45. corekit/exceptions/types.py +6 -6
  46. corekit/files/__init__.py +2 -4
  47. corekit/files/base.py +15 -2
  48. corekit/files/enum.py +0 -5
  49. corekit/files/json.py +16 -2
  50. corekit/http/__init__.py +43 -5
  51. corekit/http/api.py +24 -0
  52. corekit/http/client.py +100 -73
  53. corekit/http/exceptions.py +140 -0
  54. corekit/http/response.py +50 -1
  55. corekit/http/status.py +89 -0
  56. corekit/jobs/runner.py +12 -1
  57. corekit/jobs/task.py +23 -2
  58. corekit/log_monitor/models.py +8 -2
  59. corekit/log_monitor/service.py +77 -38
  60. corekit/notifications/base.py +18 -10
  61. corekit/observability/__init__.py +9 -2
  62. corekit/observability/benchmarkable.py +23 -5
  63. corekit/observability/loggable.py +21 -0
  64. corekit/observability/request_context.py +55 -2
  65. corekit/observability/timing/timer.py +4 -2
  66. corekit/registry/__init__.py +2 -2
  67. corekit/registry/registry.py +55 -14
  68. corekit/schemas/enum.py +22 -1
  69. corekit/schemas/types.py +6 -1
  70. corekit/serialization/__init__.py +2 -0
  71. corekit/serialization/pickle_file.py +61 -0
  72. corekit/serialization/serializable.py +22 -2
  73. corekit/serialization/serializer.py +9 -2
  74. corekit/utils/collections.py +22 -13
  75. corekit/utils/payload.py +12 -0
  76. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/METADATA +7 -7
  77. python_corekit-0.3.0.dist-info/RECORD +145 -0
  78. corekit/constants.py +0 -45
  79. corekit/exceptions/http/exceptions.py +0 -37
  80. corekit/files/pickle.py +0 -12
  81. python_corekit-0.2.0.dist-info/RECORD +0 -143
  82. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  84. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
@@ -3,7 +3,7 @@ SQL building blocks: a Connectable session, statement primitives and a base tabl
3
3
  """
4
4
 
5
5
  from corekit.connections.sql.connection import PaginatedResult, SQLConnection
6
- from corekit.connections.sql.fields import JSONBField, PydanticJSON
6
+ from corekit.connections.sql.fields import JSONBField, JsonModelField, PydanticJSON
7
7
  from corekit.connections.sql.operations import (
8
8
  Conditional,
9
9
  DdlOperation,
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "DmlOperation",
26
26
  "DqlOperation",
27
27
  "Insert",
28
+ "JsonModelField",
28
29
  "JSONBField",
29
30
  "NamedTable",
30
31
  "Operation",
@@ -24,7 +24,7 @@ from sqlmodel import Session, SQLModel, create_engine, select
24
24
 
25
25
  from corekit.config import get_settings
26
26
  from corekit.connections import Connectable
27
- from corekit.connections.sql.operations import DmlOperation, DqlOperation, Operation
27
+ from corekit.connections.sql.operations import DqlOperation, Operation, Select
28
28
  from corekit.connections.sql.query import Query
29
29
  from corekit.connections.sql.table import NamedTable
30
30
 
@@ -204,12 +204,14 @@ class SQLConnection(Connectable):
204
204
  """
205
205
  Create any tables present in SQLModel metadata but missing from the database.
206
206
  """
207
+ self.require_writable()
207
208
  SQLModel.metadata.create_all(_get_engine(self._connection_url))
208
209
 
209
210
  def exec_ddl(self, sql: str) -> None:
210
211
  """
211
212
  Run a raw DDL statement. Intended for migrations, not application code.
212
213
  """
214
+ self.require_writable()
213
215
  self._start_session()
214
216
  self.session.exec(text(sql))
215
217
  self.session.commit()
@@ -222,6 +224,7 @@ class SQLConnection(Connectable):
222
224
  """
223
225
  Insert new rows.
224
226
  """
227
+ self.require_writable()
225
228
  self._start_session()
226
229
  self.session.add_all(items)
227
230
  self.session.commit()
@@ -230,6 +233,7 @@ class SQLConnection(Connectable):
230
233
  """
231
234
  Insert or update rows. Slower than insert, but tolerates existing ids.
232
235
  """
236
+ self.require_writable()
233
237
  self._start_session()
234
238
  for item in items:
235
239
  self.session.merge(item)
@@ -239,6 +243,7 @@ class SQLConnection(Connectable):
239
243
  """
240
244
  Delete a row by id.
241
245
  """
246
+ self.require_writable()
242
247
  item = self.fetch_one_by_id(table, identifier)
243
248
  if item is None:
244
249
  return
@@ -296,8 +301,12 @@ class SQLConnection(Connectable):
296
301
  def fetch_one_by_condition(self, table: type[NamedTable], key: Any, value: Any) -> NamedTable | None:
297
302
  """
298
303
  The first row where a column equals a value.
304
+
305
+ ``key`` may be a column attribute (``Person.city``) or a column name
306
+ string resolved against ``table``.
299
307
  """
300
- return self.first(select(table).where(key == value))
308
+ column = getattr(table, key) if isinstance(key, str) else key
309
+ return self.first(select(table).where(column == value))
301
310
 
302
311
  def exists(self, table: type[NamedTable], identifier: str) -> bool:
303
312
  """
@@ -321,6 +330,16 @@ class SQLConnection(Connectable):
321
330
  """
322
331
  return list(self._exec(query.build()))
323
332
 
333
+ def delete_many_by_query(self, query: Query) -> None:
334
+ """
335
+ Delete every row matching a Query.
336
+ """
337
+ self.require_writable()
338
+ self._start_session()
339
+ for item in self.fetch_many_by_query(query):
340
+ self.session.delete(item)
341
+ self.session.commit()
342
+
324
343
  def execute(self, operation: Operation) -> Any:
325
344
  """
326
345
  Run an operation, committing if it writes.
@@ -328,8 +347,10 @@ class SQLConnection(Connectable):
328
347
  A read returns its rows; a write returns the result, whose
329
348
  ``rowcount`` reports how many rows it touched.
330
349
  """
350
+ if operation.writes:
351
+ self.require_writable()
331
352
  result = self._exec(operation.build())
332
- if isinstance(operation, DmlOperation):
353
+ if operation.writes:
333
354
  self.session.commit()
334
355
  return result
335
356
 
@@ -352,10 +373,23 @@ class SQLConnection(Connectable):
352
373
  self._start_session()
353
374
  return self.session.exec(select(func.count()).select_from(table)).one()
354
375
 
355
- def paginate(self, table: type[NamedTable], offset: int = 0, limit: int = 50) -> PaginatedResult:
376
+ def paginate(
377
+ self,
378
+ table: type[NamedTable],
379
+ offset: int = 0,
380
+ limit: int = 50,
381
+ *,
382
+ order_by: Any = None,
383
+ desc: bool = False,
384
+ ) -> PaginatedResult:
356
385
  """
357
386
  A page of rows, together with the total row count.
387
+
388
+ Pages are ordered: pass ``order_by`` for a column, or omit it to order
389
+ by ``table.id`` so offsets are stable.
358
390
  """
359
391
  total = self.count(table)
360
- rows = list(self._exec(select(table).offset(offset).limit(limit)))
392
+ query = Select(table=table).offset(offset).limit(limit)
393
+ query.order_by(order_by if order_by is not None else table.id, desc=desc)
394
+ rows = self.fetch(query)
361
395
  return PaginatedResult(rows=rows, total=total)
@@ -2,6 +2,6 @@
2
2
  Custom SQL column types.
3
3
  """
4
4
 
5
- from corekit.connections.sql.fields.jsonb import JSONBField, PydanticJSON
5
+ from corekit.connections.sql.fields.jsonb import JSONBField, JsonModelField, PydanticJSON
6
6
 
7
- __all__ = ["JSONBField", "PydanticJSON"]
7
+ __all__ = ["JsonModelField", "JSONBField", "PydanticJSON"]
@@ -1,18 +1,21 @@
1
1
  """
2
2
  Columns that store a Pydantic model as JSON.
3
3
 
4
- ``JSONBField`` round-trips a model through a JSON column, so a table can hold
5
- structured data without a separate table and without hand-written encoding at
6
- every call site::
4
+ ``JsonModelField`` round-trips a model through a JSON column, so a table can
5
+ hold structured data without a separate table and without hand-written encoding
6
+ at every call site::
7
7
 
8
8
  class Profile(BaseModel):
9
9
  theme: str = "dark"
10
10
 
11
11
  class User(NamedTable, table=True):
12
12
  id: str = Field(primary_key=True)
13
- profile: Profile = JSONBField(Profile)
13
+ profile: Profile = JsonModelField(Profile)
14
14
 
15
15
  Reading ``user.profile`` gives a ``Profile``, not a dict.
16
+
17
+ The SQLAlchemy ``impl`` is ``JSON``, not PostgreSQL ``JSONB``. The historical
18
+ ``JSONBField`` name is kept as an alias.
16
19
  """
17
20
 
18
21
  from typing import Any
@@ -22,7 +25,7 @@ from sqlalchemy import Column
22
25
  from sqlalchemy.types import JSON, TypeDecorator
23
26
  from sqlmodel import Field
24
27
 
25
- __all__ = ["JSONBField", "PydanticJSON"]
28
+ __all__ = ["JsonModelField", "JSONBField", "PydanticJSON"]
26
29
 
27
30
 
28
31
  class PydanticJSON(TypeDecorator):
@@ -60,8 +63,12 @@ class PydanticJSON(TypeDecorator):
60
63
  return self.pydantic_model(**value)
61
64
 
62
65
 
63
- def JSONBField(pydantic_model: type[BaseModel], **kwargs: Any) -> Any: # noqa: N802 - reads as a field constructor
66
+ def JsonModelField(pydantic_model: type[BaseModel], **kwargs: Any) -> Any: # noqa: N802 - reads as a field constructor
64
67
  """
65
68
  Declare a column holding ``pydantic_model`` as JSON.
66
69
  """
67
70
  return Field(sa_column=Column(PydanticJSON(pydantic_model)), **kwargs)
71
+
72
+
73
+ #: Historical name. The column type is SQLAlchemy ``JSON``, not PostgreSQL JSONB.
74
+ JSONBField = JsonModelField
@@ -24,7 +24,9 @@ from corekit.connections.sql.migration.base import Migration
24
24
  from corekit.connections.sql.migration.operations import (
25
25
  AddColumn,
26
26
  AlterColumnType,
27
+ ColumnSpec,
27
28
  CreateIndex,
29
+ CreateTable,
28
30
  DataMigrationOperation,
29
31
  DropColumn,
30
32
  DropColumnDefault,
@@ -41,7 +43,9 @@ from corekit.connections.sql.migration.table import SchemaMigration
41
43
  __all__ = [
42
44
  "AddColumn",
43
45
  "AlterColumnType",
46
+ "ColumnSpec",
44
47
  "CreateIndex",
48
+ "CreateTable",
45
49
  "DataMigrationOperation",
46
50
  "DropColumn",
47
51
  "DropColumnDefault",
@@ -360,6 +360,72 @@ class DropIndex(MigrationOperation):
360
360
  # ---------------------------------------------------------------------------
361
361
 
362
362
 
363
+ @dataclass(frozen=True)
364
+ class ColumnSpec:
365
+ """
366
+ One column in a ``CreateTable`` statement.
367
+
368
+ Types and defaults are SQL text, as on ``AddColumn``, not Python values.
369
+ Write ``default="FALSE"`` or ``default="'red'"``, not ``False`` or
370
+ ``"red"``.
371
+ """
372
+
373
+ name: str
374
+ dtype: str
375
+ nullable: bool = True
376
+ primary_key: bool = False
377
+ default: str | None = None
378
+
379
+
380
+ @dataclass
381
+ class CreateTable(MigrationOperation):
382
+ """
383
+ Create a table from an explicit column list.
384
+
385
+ This is a new operation. It does not change the SQL or checksum of
386
+ ``AddColumn`` and the other existing operations. Listing the columns here,
387
+ rather than reading a model at run time, is what keeps an applied
388
+ migration's checksum stable when that model later gains a field.
389
+
390
+ ``if_not_exists`` defaults to True. Both SQLite and Postgres accept
391
+ ``CREATE TABLE IF NOT EXISTS``.
392
+ """
393
+
394
+ table: type[NamedTable] | str
395
+ columns: list[ColumnSpec]
396
+ if_not_exists: bool = True
397
+
398
+ def to_sql(self) -> str:
399
+ """
400
+ Example: CREATE TABLE [IF NOT EXISTS] <table_name> (<column> <dtype> ...)
401
+ """
402
+ if not self.columns:
403
+ raise ValueError("CreateTable requires at least one column")
404
+
405
+ definitions = ", ".join(self._column_sql(column) for column in self.columns)
406
+ exists = "IF NOT EXISTS " if self.if_not_exists else ""
407
+ return f"CREATE TABLE {exists}{self.table_name} ({definitions})"
408
+
409
+ @staticmethod
410
+ def _column_sql(column: ColumnSpec) -> str:
411
+ parts = [column.name, column.dtype]
412
+ if column.primary_key:
413
+ parts.append("PRIMARY KEY")
414
+ if not column.nullable:
415
+ parts.append("NOT NULL")
416
+ if column.default is not None:
417
+ parts.append(f"DEFAULT {column.default}")
418
+ return " ".join(parts)
419
+
420
+ def canonical(self) -> str:
421
+ rendered = ", ".join(
422
+ f"{column.name}:{column.dtype}:nullable={column.nullable}:"
423
+ f"primary_key={column.primary_key}:default={column.default}"
424
+ for column in self.columns
425
+ )
426
+ return f"CreateTable(table={self.table_name}, columns=[{rendered}], if_not_exists={self.if_not_exists})"
427
+
428
+
363
429
  @dataclass
364
430
  class DropTable(MigrationOperation):
365
431
  table: type[NamedTable] | str
@@ -409,8 +475,9 @@ class DataMigrationOperation(MigrationOperation):
409
475
  Executes a Python function for data migrations (seeding, transformations).
410
476
 
411
477
  The function receives a SQLConnection and should use ORM methods to
412
- read/write data. The `name` is used for checksumming - changing it will
413
- invalidate the checksum, so choose a stable, descriptive name.
478
+ read/write data. The checksum is the ``name`` only, not the function.
479
+ Editing the callable without a new name is invisible to history, so a
480
+ changed function requires a new name. Choose a stable, descriptive one.
414
481
 
415
482
  Example:
416
483
  DataMigrationOperation(
@@ -3,7 +3,7 @@ Statement primitives: the shapes a database operation can take.
3
3
  """
4
4
 
5
5
  from abc import ABC, abstractmethod
6
- from typing import Any
6
+ from typing import Any, ClassVar
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
@@ -24,6 +24,10 @@ class Operation(BaseModel, ABC):
24
24
 
25
25
  model_config = {"arbitrary_types_allowed": True}
26
26
 
27
+ #: True when executing this operation changes rows and must be committed.
28
+ #: Conditional writes (update, delete) and insert both set this; reads do not.
29
+ writes: ClassVar[bool] = False
30
+
27
31
  table: type[NamedTable]
28
32
 
29
33
  @abstractmethod
@@ -79,9 +83,14 @@ class DqlOperation(Conditional, ABC):
79
83
 
80
84
  class DmlOperation(Conditional, ABC):
81
85
  """
82
- A write against rows: insert, update or delete.
86
+ A conditional write against rows: update or delete.
87
+
88
+ Insert is also a write, but it has no ``where``, so it is not this class.
89
+ Both set ``writes`` so a connection commits them.
83
90
  """
84
91
 
92
+ writes: ClassVar[bool] = True
93
+
85
94
 
86
95
  class DdlOperation(Operation, ABC):
87
96
  """
@@ -2,7 +2,7 @@
2
2
  The statements an application runs: select, insert, update and delete.
3
3
  """
4
4
 
5
- from typing import Any
5
+ from typing import Any, ClassVar
6
6
 
7
7
  from pydantic import Field
8
8
  from sqlmodel import delete as _delete
@@ -18,7 +18,7 @@ __all__ = ["Delete", "Insert", "Select", "Update"]
18
18
 
19
19
  class Select(DqlOperation):
20
20
  """
21
- Read rows, optionally ordered and capped.
21
+ Read rows, optionally ordered, offset and capped.
22
22
 
23
23
  Select(table=User).where(User.age >= 18).order_by(User.name).limit(10)
24
24
 
@@ -26,11 +26,14 @@ class Select(DqlOperation):
26
26
  predicate language ``Dataset`` filters with::
27
27
 
28
28
  Select(table=User).where(Field("age") >= 18)
29
+
30
+ ``limit(-1)`` means no cap. ``limit(0)`` returns no rows.
29
31
  """
30
32
 
31
33
  order_by_field: Any = None
32
34
  order_by_desc: bool = False
33
35
  limit_count: int = -1
36
+ offset_count: int = 0
34
37
 
35
38
  def order_by(self, field: Any, desc: bool = False) -> "Select":
36
39
  """
@@ -42,11 +45,20 @@ class Select(DqlOperation):
42
45
 
43
46
  def limit(self, limit: int) -> "Select":
44
47
  """
45
- Cap the number of rows returned. A non-positive value means no limit.
48
+ Cap the number of rows returned.
49
+
50
+ ``-1`` means no limit. ``0`` means zero rows.
46
51
  """
47
52
  self.limit_count = limit
48
53
  return self
49
54
 
55
+ def offset(self, offset: int) -> "Select":
56
+ """
57
+ Skip the first ``offset`` rows.
58
+ """
59
+ self.offset_count = offset
60
+ return self
61
+
50
62
  def build(self) -> Any:
51
63
  """
52
64
  Produce the select statement.
@@ -59,7 +71,10 @@ class Select(DqlOperation):
59
71
  order_by_field = _desc(order_by_field)
60
72
  statement = statement.order_by(order_by_field)
61
73
 
62
- if self.limit_count > 0:
74
+ if self.offset_count > 0:
75
+ statement = statement.offset(self.offset_count)
76
+
77
+ if self.limit_count >= 0:
63
78
  statement = statement.limit(self.limit_count)
64
79
 
65
80
  return statement
@@ -121,7 +136,10 @@ class Update(DmlOperation):
121
136
 
122
137
  class Insert(Operation):
123
138
  """
124
- Add rows.
139
+ Add rows. A write, so ``SQLConnection.execute`` commits it.
140
+
141
+ Not a ``DmlOperation``: an insert has no rows to narrow, so it does not
142
+ take ``where``.
125
143
 
126
144
  Insert(table=User, rows=[{"name": "Ada"}, {"name": "Bob"}])
127
145
 
@@ -129,6 +147,8 @@ class Insert(Operation):
129
147
  statement. To add model instances, use ``SQLConnection.insert``.
130
148
  """
131
149
 
150
+ writes: ClassVar[bool] = True
151
+
132
152
  rows: list[dict[str, Any]] = Field(default_factory=list)
133
153
 
134
154
  def add(self, **values: Any) -> "Insert":
@@ -2,7 +2,6 @@
2
2
  Base table type.
3
3
  """
4
4
 
5
- import json
6
5
  import logging
7
6
  from typing import Any, ClassVar
8
7
 
@@ -33,6 +32,10 @@ class NamedTable(SQLModel):
33
32
  Subclasses register themselves by name, so a table class can be resolved
34
33
  from a string -- useful for imports, exports and admin tooling that work
35
34
  with table names rather than imported classes.
35
+
36
+ ``table_name`` is the database table name (``__tablename__``). Use
37
+ ``get_display_name`` for a human-readable label. Structured JSON columns
38
+ belong on ``JsonModelField`` / ``PydanticJSON``, not ad-hoc encode helpers.
36
39
  """
37
40
 
38
41
  __registry__: SmartRegistry = SmartRegistry()
@@ -65,9 +68,9 @@ class NamedTable(SQLModel):
65
68
  @property
66
69
  def table_name(self) -> str:
67
70
  """
68
- The name of the table this record belongs to.
71
+ The database table name for this row (``__tablename__``).
69
72
  """
70
- return type(self).__name__
73
+ return type(self).__tablename__
71
74
 
72
75
  @classmethod
73
76
  def get_display_name(cls) -> str:
@@ -92,31 +95,6 @@ class NamedTable(SQLModel):
92
95
 
93
96
  def export(self) -> TableExportItem:
94
97
  """
95
- This row as a portable record, tagged with its table.
98
+ This row as a portable record, tagged with its database table name.
96
99
  """
97
100
  return TableExportItem(table_name=self.table_name, data=self.model_dump())
98
-
99
- @staticmethod
100
- def _serialize(value: Any) -> str:
101
- return json.dumps(value)
102
-
103
- @staticmethod
104
- def _deserialize(value: str) -> Any:
105
- try:
106
- return json.loads(value)
107
- except (TypeError, ValueError):
108
- logger.error(f"Unable to deserialize value: {value!r}")
109
- raise
110
-
111
- def serialize(self, values: dict[str, Any], key: str) -> dict[str, Any]:
112
- """
113
- Replace ``values[key]`` with its JSON encoding.
114
- """
115
- values[key] = self._serialize(values.get(key))
116
- return values
117
-
118
- def deserialize(self, key: str) -> Any:
119
- """
120
- Decode the JSON stored in the named field.
121
- """
122
- return self._deserialize(getattr(self, key))
@@ -1 +1,3 @@
1
- from .hasher import Hasher
1
+ from .hasher import Hasher, MissingSaltError
2
+
3
+ __all__ = ["Hasher", "MissingSaltError"]
@@ -3,5 +3,5 @@ EMPTY_HASH = ""
3
3
 
4
4
  # There is deliberately no default salt. A salt shipped with a library is shared
5
5
  # by every install, which defeats the purpose of salting. Supply one through
6
- # configuration -- see `corekit.config.CorekitSettings.crypto_salt` and
7
- # `corekit.crypto.hasher.Hasher`.
6
+ # configuration -- COREKIT_CRYPTO__SALT, or [crypto] salt in a config file --
7
+ # or pass salt= to Hasher.
corekit/crypto/hasher.py CHANGED
@@ -4,22 +4,27 @@ from typing import Any
4
4
  from corekit.config import get_settings
5
5
  from corekit.crypto.constants import EMPTY_HASH, HASH_JOINER
6
6
  from corekit.crypto.enum import SaltMethod
7
+ from corekit.exceptions import InternalCoreException, Retryability
7
8
  from corekit.observability.loggable import Loggable
8
9
 
9
10
 
10
- class MissingSaltError(RuntimeError):
11
+ class MissingSaltError(InternalCoreException):
11
12
  """
12
13
  Raised when hashing is attempted without a configured salt.
13
14
  """
14
15
 
16
+ def __init__(self, message: str, *, error: str | None = None) -> None:
17
+ super().__init__(message, retryable=Retryability.NON_RETRYABLE, error=error)
18
+
15
19
 
16
20
  class Hasher(Loggable):
17
21
  """
18
22
  Salted SHA-256 hashing.
19
23
 
20
24
  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.
25
+ ``COREKIT_CRYPTO__SALT`` or ``[crypto] salt`` in a corekit config file.
26
+ Resolution is deferred until first use so that importing this module
27
+ never fails.
23
28
  """
24
29
 
25
30
  def __init__(
@@ -41,7 +46,7 @@ class Hasher(Loggable):
41
46
  if not salt:
42
47
  raise MissingSaltError(
43
48
  "No crypto salt configured. Pass salt=... to Hasher, set "
44
- "COREKIT_CRYPTO_SALT, or add crypto_salt to your corekit config. "
49
+ "COREKIT_CRYPTO__SALT, or add salt under [crypto] in your corekit config. "
45
50
  'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
46
51
  )
47
52
  return salt
corekit/data/dataset.py CHANGED
@@ -268,8 +268,14 @@ class Dataset:
268
268
  def at(self, idx: int) -> Any:
269
269
  return self._records[idx]
270
270
 
271
- def get_record(self, record_id: Any) -> Any:
272
- return self._id_index[record_id]
271
+ def get_record(self, record_id: Any) -> Any | None:
272
+ """
273
+ Look up a record by id, or ``None`` if it is missing.
274
+
275
+ Matches SQL ``fetch_one_by_id``: identity lookup returns absence as
276
+ ``None`` rather than raising.
277
+ """
278
+ return self._id_index.get(record_id)
273
279
 
274
280
  # -- removing whole records ----------------------------------------------
275
281
 
@@ -8,9 +8,9 @@ values or against each other::
8
8
  Field("first_name") == Field("nickname")
9
9
  (Field("age") > 20) & ~Field("name").contains("test")
10
10
 
11
- Every node can also emit a backend query: ``to_mongo``, ``to_elasticsearch`` or
12
- ``to_sqlalchemy``. ``Target`` names the context an operand is resolved for, so a
13
- new backend needs one more translation method and no changes to the tree.
11
+ A node evaluates against a record in Python. ``to_sqlalchemy`` is the other
12
+ implementation of the same predicate. Operators SQL cannot mean the same way
13
+ are refused there rather than compiled into a different query.
14
14
  """
15
15
 
16
16
  from corekit.data.expressions.comparison import (