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.
Files changed (64) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +237 -0
  3. corekit/api/lifespan.py +210 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/worker.py +65 -65
  7. corekit/config/settings.py +3 -3
  8. corekit/connections/sql/__init__.py +31 -3
  9. corekit/connections/sql/connection.py +19 -0
  10. corekit/connections/sql/migration/__init__.py +5 -5
  11. corekit/connections/sql/migration/base.py +3 -3
  12. corekit/connections/sql/migration/operations.py +66 -42
  13. corekit/connections/sql/migration/registry.py +2 -2
  14. corekit/connections/sql/operations/__init__.py +24 -0
  15. corekit/connections/sql/operations/base.py +102 -0
  16. corekit/connections/sql/operations/statements.py +150 -0
  17. corekit/connections/sql/query.py +4 -62
  18. corekit/connections/sql/table.py +30 -4
  19. corekit/constants.py +45 -45
  20. corekit/crypto/constants.py +4 -4
  21. corekit/data/__init__.py +8 -0
  22. corekit/data/expressions/__init__.py +10 -2
  23. corekit/data/expressions/comparison.py +184 -104
  24. corekit/data/expressions/expression.py +103 -98
  25. corekit/data/expressions/operator.py +54 -0
  26. corekit/data/expressions/target.py +21 -0
  27. corekit/data/record.py +147 -147
  28. corekit/data/stats.py +159 -157
  29. corekit/decorators/__init__.py +2 -2
  30. corekit/decorators/exception_handling.py +2 -1
  31. corekit/etl/connection.py +44 -44
  32. corekit/events/websocket.py +3 -2
  33. corekit/exceptions/__init__.py +18 -0
  34. corekit/http/__init__.py +13 -0
  35. corekit/jobs/__init__.py +26 -0
  36. corekit/jobs/registry.py +87 -0
  37. corekit/jobs/runner.py +69 -0
  38. corekit/jobs/task.py +152 -0
  39. corekit/observability/__init__.py +5 -3
  40. corekit/observability/request_context.py +135 -0
  41. corekit/registry/__init__.py +11 -6
  42. corekit/registry/ordered.py +86 -0
  43. corekit/schemas/__init__.py +10 -0
  44. corekit/schemas/enum.py +49 -49
  45. corekit/schemas/models/arbitrary.py +11 -11
  46. corekit/schemas/pydantic/fields.py +35 -35
  47. corekit/schemas/types.py +40 -40
  48. corekit/serialization/__init__.py +22 -0
  49. corekit/serialization/serializer.py +1 -1
  50. corekit/utils/__init__.py +59 -5
  51. corekit/utils/coercion.py +118 -0
  52. corekit/utils/collections.py +115 -0
  53. corekit/utils/ids.py +61 -5
  54. corekit/utils/payload.py +100 -0
  55. corekit/utils/raise_exc.py +8 -8
  56. corekit/utils/text.py +56 -0
  57. corekit/utils/time.py +74 -21
  58. corekit/utils/validators.py +15 -15
  59. corekit/utils/void.py +8 -8
  60. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/METADATA +105 -100
  61. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/RECORD +64 -46
  62. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
  63. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
  64. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/top_level.txt +0 -0
corekit/api/routers.py CHANGED
@@ -12,18 +12,45 @@ the subscript syntax::
12
12
  return await router.handler.list_users()
13
13
  """
14
14
 
15
+ import inspect
15
16
  from typing import Any, Generic, Type, TypeVar, cast
16
17
 
17
18
  from fastapi import APIRouter, FastAPI
18
19
 
19
20
  from corekit.api.handler import BaseHandler
20
21
  from corekit.observability.benchmarkable import Benchmarkable
22
+ from corekit.registry import OrderedRegistry
21
23
 
22
- __all__ = ["SimpleRouter", "SmartRouter"]
24
+ __all__ = ["CatchAllRouter", "SimpleRouter", "SmartRouter", "router_registry"]
23
25
 
24
26
  H = TypeVar("H", bound=BaseHandler)
25
27
 
26
28
 
29
+ class _RouterRegistry(OrderedRegistry["SimpleRouter"]):
30
+ """
31
+ Every ``SimpleRouter`` built in this process, in construction order.
32
+
33
+ ``Application.discover_routers`` imports a package to populate this, then
34
+ mounts what is left unclaimed. A router that has been included into another
35
+ router claims itself, so only top-level routers are mounted.
36
+ """
37
+
38
+ @property
39
+ def unclaimed(self) -> list["SimpleRouter"]:
40
+ """
41
+ Top-level routers, with any catch-all last.
42
+
43
+ Sorting is stable, so everything else keeps construction order.
44
+ """
45
+ return sorted(
46
+ self.where(lambda router: not router.claimed),
47
+ key=lambda router: isinstance(router, CatchAllRouter),
48
+ )
49
+
50
+
51
+ router_registry = _RouterRegistry()
52
+
53
+
27
54
  class SimpleRouter(APIRouter, Benchmarkable):
28
55
  """
29
56
  An APIRouter that can log, time itself, and mount itself onto a parent.
@@ -39,13 +66,54 @@ class SimpleRouter(APIRouter, Benchmarkable):
39
66
  APIRouter.__init__(self, **kwargs)
40
67
  Benchmarkable.__init__(self)
41
68
 
69
+ # Discovery needs to see every router that was built, and to tell a
70
+ # top-level one from a sub-router. Both facts are recorded here rather
71
+ # than inferred later, when the import that created them is long done.
72
+ self._claimed = False
73
+ self._origin_module = self._caller_module()
74
+ router_registry.register(self)
75
+
76
+ @property
77
+ def claimed(self) -> bool:
78
+ """Whether a parent router has taken ownership of this one."""
79
+ return self._claimed
80
+
81
+ def claim(self) -> None:
82
+ """
83
+ Mark this router as owned by a parent, so discovery skips it.
84
+
85
+ Called by ``include`` rather than by hand: a router that a parent has
86
+ already mounted must not also be mounted at the application root.
87
+ """
88
+ self._claimed = True
89
+
90
+ @staticmethod
91
+ def _caller_module() -> str:
92
+ """
93
+ The module that constructed this router.
94
+
95
+ ``type(self).__module__`` names where the class is defined, which is
96
+ always corekit; discovery logs need where the instance was built. Walk
97
+ out of corekit's own frames to find it.
98
+ """
99
+ for frame in inspect.stack()[1:]:
100
+ module = frame.frame.f_globals.get("__name__", "")
101
+ if not module.startswith("corekit."):
102
+ return module
103
+ return "<unknown>"
104
+
42
105
  def include(self, parent: APIRouter | FastAPI, *args: Any, **kwargs: Any) -> None:
43
106
  """
44
107
  Add this router to a parent router or application.
45
108
 
46
109
  The inverse of ``parent.include_router(self)``, so a module can hand
47
110
  itself over: ``admin_router.include(app)``.
111
+
112
+ Including into another router claims this one, so ``discover_routers``
113
+ will not mount it a second time at the application root.
48
114
  """
115
+ if isinstance(parent, APIRouter):
116
+ self.claim()
49
117
  parent.include_router(self, *args, **kwargs)
50
118
 
51
119
 
@@ -113,3 +181,43 @@ class SmartRouter(SimpleRouter, Generic[H]):
113
181
  Replace the handler. Assigning a double is the supported way to test a router.
114
182
  """
115
183
  self._handler = handler
184
+
185
+
186
+ class CatchAllRouter(SmartRouter[H]):
187
+ """
188
+ The router of last resort, mounted after every other route.
189
+
190
+ A single-page app needs one of these: a ``/{path:path}`` route that serves
191
+ ``index.html`` so the client-side router can take over. Mounted in the wrong
192
+ order it shadows the entire API, because it matches everything.
193
+
194
+ Being a distinct type rather than an ordering flag means that position is a
195
+ property of what the router *is*. ``discover_routers`` always mounts it last,
196
+ and a second instance is refused -- two catch-alls means one of them silently
197
+ never matches, which is not a state worth allowing.
198
+ """
199
+
200
+ _instance: "CatchAllRouter[Any] | None" = None
201
+
202
+ def __init__(self, handler: H | None = None, **kwargs: Any) -> None:
203
+ """
204
+ Build the catch-all. Raises if one already exists in this process.
205
+ """
206
+ existing = CatchAllRouter._instance
207
+ if existing is not None:
208
+ raise RuntimeError(
209
+ f"A CatchAllRouter already exists ({existing._origin_module}). "
210
+ f"Only one may exist: it matches every unmatched path, so a second "
211
+ f"could never receive a request. Delete one, or make it a SmartRouter "
212
+ f"with an explicit prefix."
213
+ )
214
+
215
+ super().__init__(handler=handler, **kwargs)
216
+ CatchAllRouter._instance = self
217
+
218
+ @classmethod
219
+ def reset(cls) -> None:
220
+ """
221
+ Forget the registered instance. For tests that build more than one app.
222
+ """
223
+ cls._instance = None
@@ -1,65 +1,65 @@
1
- import asyncio
2
- import logging
3
- import threading
4
- import time
5
-
6
-
7
- class ThreadWorker(threading.Thread):
8
- def __init__(
9
- self,
10
- can_start: threading.Event,
11
- loop: asyncio.AbstractEventLoop | None = None,
12
- timeout: int = 3,
13
- delay: float = 1.0,
14
- ) -> None:
15
- super().__init__(daemon=True)
16
- self.loop = loop
17
- self.timeout = timeout
18
- self.delay = delay
19
- self.running = True # Control flag for stopping the thread
20
- self.can_start: threading.Event = can_start
21
- self.logger = logging.getLogger(self.__class__.__name__)
22
- self._logging_prefix = f"[{self.__class__.__name__}]"
23
-
24
- @property
25
- def logging_prefix(self) -> str:
26
- return self._logging_prefix
27
-
28
- def info(self, message: str) -> None:
29
- self.logger.info(f"{self.logging_prefix}: {message}")
30
-
31
- def warning(self, message: str) -> None:
32
- self.logger.warning(f"{self.logging_prefix}: {message}")
33
-
34
- def error(self, message: str) -> None:
35
- self.logger.error(f"{self.logging_prefix}: {message}")
36
-
37
- def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
38
- self.logger.info("Updating event loop")
39
- self.loop = loop
40
-
41
- def stop(self) -> None:
42
- """
43
- Stops the worker gracefully
44
- """
45
- self.running = False
46
- self.info("Stopping ThreadWorker")
47
-
48
- def run(self) -> None:
49
- self.info("ThreadWorker started")
50
- try:
51
- self.info("ThreadWorker waiting until able to start")
52
- self.can_start.wait()
53
-
54
- self.info("can_start flag set. Starting ThreadWorker")
55
- self.process()
56
-
57
- except Exception as exc:
58
- self.error(f"Error in ThreadWorker: {exc}")
59
- time.sleep(self.delay)
60
-
61
- finally:
62
- self.info("ThreadWorker ending")
63
-
64
- def process(self) -> None:
65
- raise NotImplementedError()
1
+ import asyncio
2
+ import logging
3
+ import threading
4
+ import time
5
+
6
+
7
+ class ThreadWorker(threading.Thread):
8
+ def __init__(
9
+ self,
10
+ can_start: threading.Event,
11
+ loop: asyncio.AbstractEventLoop | None = None,
12
+ timeout: int = 3,
13
+ delay: float = 1.0,
14
+ ) -> None:
15
+ super().__init__(daemon=True)
16
+ self.loop = loop
17
+ self.timeout = timeout
18
+ self.delay = delay
19
+ self.running = True # Control flag for stopping the thread
20
+ self.can_start: threading.Event = can_start
21
+ self.logger = logging.getLogger(self.__class__.__name__)
22
+ self._logging_prefix = f"[{self.__class__.__name__}]"
23
+
24
+ @property
25
+ def logging_prefix(self) -> str:
26
+ return self._logging_prefix
27
+
28
+ def info(self, message: str) -> None:
29
+ self.logger.info(f"{self.logging_prefix}: {message}")
30
+
31
+ def warning(self, message: str) -> None:
32
+ self.logger.warning(f"{self.logging_prefix}: {message}")
33
+
34
+ def error(self, message: str) -> None:
35
+ self.logger.error(f"{self.logging_prefix}: {message}")
36
+
37
+ def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
38
+ self.logger.info("Updating event loop")
39
+ self.loop = loop
40
+
41
+ def stop(self) -> None:
42
+ """
43
+ Stops the worker gracefully
44
+ """
45
+ self.running = False
46
+ self.info("Stopping ThreadWorker")
47
+
48
+ def run(self) -> None:
49
+ self.info("ThreadWorker started")
50
+ try:
51
+ self.info("ThreadWorker waiting until able to start")
52
+ self.can_start.wait()
53
+
54
+ self.info("can_start flag set. Starting ThreadWorker")
55
+ self.process()
56
+
57
+ except Exception as exc:
58
+ self.error(f"Error in ThreadWorker: {exc}")
59
+ time.sleep(self.delay)
60
+
61
+ finally:
62
+ self.info("ThreadWorker ending")
63
+
64
+ def process(self) -> None:
65
+ raise NotImplementedError()
@@ -89,9 +89,9 @@ class CryptoSettings(BaseModel):
89
89
  """
90
90
  Hashing configuration.
91
91
 
92
- ``salt`` has no default on purpose: the value that used to be hardcoded is
93
- in git history and must be treated as compromised, so hashing raises until
94
- one is supplied.
92
+ ``salt`` has no default on purpose. A salt shipped with a library is shared
93
+ by every install, which defeats the point of salting, so hashing raises
94
+ until one is supplied.
95
95
  """
96
96
 
97
97
  salt: str | None = None
@@ -1,10 +1,38 @@
1
1
  """
2
- SQL building blocks: a Connectable session, a query builder and a base table.
2
+ SQL building blocks: a Connectable session, statement primitives and a base table.
3
3
  """
4
4
 
5
5
  from corekit.connections.sql.connection import PaginatedResult, SQLConnection
6
6
  from corekit.connections.sql.fields import JSONBField, PydanticJSON
7
+ from corekit.connections.sql.operations import (
8
+ Conditional,
9
+ DdlOperation,
10
+ Delete,
11
+ DmlOperation,
12
+ DqlOperation,
13
+ Insert,
14
+ Operation,
15
+ Select,
16
+ Update,
17
+ )
7
18
  from corekit.connections.sql.query import Query
8
- from corekit.connections.sql.table import NamedTable
19
+ from corekit.connections.sql.table import NamedTable, TableExportItem
9
20
 
10
- __all__ = ["JSONBField", "NamedTable", "PaginatedResult", "PydanticJSON", "Query", "SQLConnection"]
21
+ __all__ = [
22
+ "Conditional",
23
+ "DdlOperation",
24
+ "Delete",
25
+ "DmlOperation",
26
+ "DqlOperation",
27
+ "Insert",
28
+ "JSONBField",
29
+ "NamedTable",
30
+ "Operation",
31
+ "PaginatedResult",
32
+ "PydanticJSON",
33
+ "Query",
34
+ "Select",
35
+ "SQLConnection",
36
+ "TableExportItem",
37
+ "Update",
38
+ ]
@@ -24,6 +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
28
  from corekit.connections.sql.query import Query
28
29
  from corekit.connections.sql.table import NamedTable
29
30
 
@@ -320,6 +321,24 @@ class SQLConnection(Connectable):
320
321
  """
321
322
  return list(self._exec(query.build()))
322
323
 
324
+ def execute(self, operation: Operation) -> Any:
325
+ """
326
+ Run an operation, committing if it writes.
327
+
328
+ A read returns its rows; a write returns the result, whose
329
+ ``rowcount`` reports how many rows it touched.
330
+ """
331
+ result = self._exec(operation.build())
332
+ if isinstance(operation, DmlOperation):
333
+ self.session.commit()
334
+ return result
335
+
336
+ def fetch(self, operation: DqlOperation) -> list[NamedTable]:
337
+ """
338
+ Every row an operation selects.
339
+ """
340
+ return list(self.execute(operation))
341
+
323
342
  def fetch_one_by_query(self, query: Query) -> NamedTable | None:
324
343
  """
325
344
  The first row matching a Query.
@@ -8,7 +8,7 @@ Define a migration by subclassing ``Migration`` and listing its operations::
8
8
  name = "user_is_active"
9
9
  description = "Adds the is_active flag to users."
10
10
 
11
- def operations(self) -> list[Operation]:
11
+ def operations(self) -> list[MigrationOperation]:
12
12
  return [AddColumn(table="user", column="is_active", dtype="BOOLEAN", default="true")]
13
13
 
14
14
  Collect them from a package and run them::
@@ -25,12 +25,12 @@ from corekit.connections.sql.migration.operations import (
25
25
  AddColumn,
26
26
  AlterColumnType,
27
27
  CreateIndex,
28
- DataOperation,
28
+ DataMigrationOperation,
29
29
  DropColumn,
30
30
  DropColumnDefault,
31
31
  DropIndex,
32
32
  DropTable,
33
- Operation,
33
+ MigrationOperation,
34
34
  RenameColumn,
35
35
  RenameTable,
36
36
  SetColumnDefault,
@@ -42,7 +42,7 @@ __all__ = [
42
42
  "AddColumn",
43
43
  "AlterColumnType",
44
44
  "CreateIndex",
45
- "DataOperation",
45
+ "DataMigrationOperation",
46
46
  "DropColumn",
47
47
  "DropColumnDefault",
48
48
  "DropIndex",
@@ -50,7 +50,7 @@ __all__ = [
50
50
  "Migration",
51
51
  "MigrationChecksumError",
52
52
  "MigrationRegistry",
53
- "Operation",
53
+ "MigrationOperation",
54
54
  "RenameColumn",
55
55
  "RenameTable",
56
56
  "SchemaMigration",
@@ -5,7 +5,7 @@ A Migration is defined by:
5
5
  - version: unique positive integer, determines execution order
6
6
  - name: short human-readable label (used in logs and the DB table)
7
7
  - description: longer explanation of what and why
8
- - operations: ordered list of Operation instances to execute
8
+ - operations: ordered list of MigrationOperation instances to execute
9
9
 
10
10
  The checksum is computed at class definition time from the canonical
11
11
  representations of all operations. It is stored in the schema_migration
@@ -17,7 +17,7 @@ and will cause a hard crash.
17
17
  import hashlib
18
18
  from abc import ABC, abstractmethod
19
19
 
20
- from corekit.connections.sql.migration.operations import Operation
20
+ from corekit.connections.sql.migration.operations import MigrationOperation
21
21
 
22
22
 
23
23
  class Migration(ABC):
@@ -26,7 +26,7 @@ class Migration(ABC):
26
26
  description: str
27
27
 
28
28
  @abstractmethod
29
- def operations(self) -> list[Operation]:
29
+ def operations(self) -> list[MigrationOperation]:
30
30
  raise NotImplementedError
31
31
 
32
32
  @property