python-corekit 0.1.1__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 (62) 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/connections/sql/__init__.py +31 -3
  8. corekit/connections/sql/connection.py +19 -0
  9. corekit/connections/sql/migration/__init__.py +5 -5
  10. corekit/connections/sql/migration/base.py +3 -3
  11. corekit/connections/sql/migration/operations.py +66 -42
  12. corekit/connections/sql/migration/registry.py +2 -2
  13. corekit/connections/sql/operations/__init__.py +24 -0
  14. corekit/connections/sql/operations/base.py +102 -0
  15. corekit/connections/sql/operations/statements.py +150 -0
  16. corekit/connections/sql/query.py +4 -62
  17. corekit/connections/sql/table.py +30 -4
  18. corekit/constants.py +45 -45
  19. corekit/data/__init__.py +8 -0
  20. corekit/data/expressions/__init__.py +10 -2
  21. corekit/data/expressions/comparison.py +184 -104
  22. corekit/data/expressions/expression.py +103 -98
  23. corekit/data/expressions/operator.py +54 -0
  24. corekit/data/expressions/target.py +21 -0
  25. corekit/data/record.py +147 -147
  26. corekit/data/stats.py +159 -157
  27. corekit/decorators/__init__.py +2 -2
  28. corekit/decorators/exception_handling.py +2 -1
  29. corekit/etl/connection.py +44 -44
  30. corekit/events/websocket.py +3 -2
  31. corekit/exceptions/__init__.py +18 -0
  32. corekit/http/__init__.py +13 -0
  33. corekit/jobs/__init__.py +26 -0
  34. corekit/jobs/registry.py +87 -0
  35. corekit/jobs/runner.py +69 -0
  36. corekit/jobs/task.py +152 -0
  37. corekit/observability/__init__.py +5 -3
  38. corekit/observability/request_context.py +135 -0
  39. corekit/registry/__init__.py +11 -6
  40. corekit/registry/ordered.py +86 -0
  41. corekit/schemas/__init__.py +10 -0
  42. corekit/schemas/enum.py +49 -49
  43. corekit/schemas/models/arbitrary.py +11 -11
  44. corekit/schemas/pydantic/fields.py +35 -35
  45. corekit/schemas/types.py +40 -40
  46. corekit/serialization/__init__.py +22 -0
  47. corekit/serialization/serializer.py +1 -1
  48. corekit/utils/__init__.py +59 -5
  49. corekit/utils/coercion.py +118 -0
  50. corekit/utils/collections.py +115 -0
  51. corekit/utils/ids.py +61 -5
  52. corekit/utils/payload.py +100 -0
  53. corekit/utils/raise_exc.py +8 -8
  54. corekit/utils/text.py +56 -0
  55. corekit/utils/time.py +74 -21
  56. corekit/utils/validators.py +15 -15
  57. corekit/utils/void.py +8 -8
  58. {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/METADATA +98 -92
  59. {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/RECORD +62 -44
  60. {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
  61. {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
  62. {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/top_level.txt +0 -0
corekit/data/stats.py CHANGED
@@ -1,157 +1,159 @@
1
- import statistics
2
- from collections import Counter
3
- from dataclasses import dataclass
4
- from dataclasses import field as dataclass_field
5
- from typing import Any, Iterator
6
-
7
-
8
- @dataclass(slots=True)
9
- class FieldStats:
10
- """
11
- Common shape shared by every field's statistics
12
- """
13
-
14
- field: Any
15
- count: int
16
- missing: int
17
-
18
-
19
- @dataclass(slots=True)
20
- class NumericFieldStats(FieldStats):
21
- """
22
- Stats for a field where every non-missing value is int/float (not bool)
23
- """
24
-
25
- mean: float
26
- min: Any
27
- max: Any
28
- stdev: float
29
-
30
-
31
- @dataclass(slots=True)
32
- class CategoricalFieldStats(FieldStats):
33
- """
34
- Stats for any field that isn't purely numeric
35
- """
36
-
37
- unique: int
38
- top: Any
39
- freq: int
40
-
41
-
42
- @dataclass(slots=True)
43
- class ValueCounts:
44
- """
45
- How often each distinct value of a field occurs, most frequent first
46
- """
47
-
48
- field: Any
49
- counts: dict[Any, int]
50
-
51
- def most_common(self, n: int | None = None) -> list[tuple[Any, int]]:
52
- items = list(self.counts.items())
53
- return items[:n] if n is not None else items
54
-
55
- def top(self) -> tuple[Any, int] | None:
56
- return next(iter(self.counts.items()), None)
57
-
58
- def __iter__(self) -> Iterator[tuple[Any, int]]:
59
- return iter(self.counts.items())
60
-
61
- def __len__(self) -> int:
62
- return len(self.counts)
63
-
64
- def __getitem__(self, value: Any) -> int:
65
- return self.counts[value]
66
-
67
-
68
- @dataclass(slots=True)
69
- class FieldDescription:
70
- field: Any
71
- values: list[Any] = dataclass_field(default_factory=list)
72
- non_missing: list[Any] = dataclass_field(default_factory=list)
73
- num_values: int = dataclass_field(default=0)
74
- num_non_missing: int = dataclass_field(default=0)
75
-
76
- @classmethod
77
- def from_data(cls, data: list[Any], field: Any) -> "FieldDescription":
78
- instance = cls(field)
79
- for record in data:
80
- instance.add(record[field])
81
- return instance
82
-
83
- @property
84
- def missing(self) -> int:
85
- return self.num_values - self.num_non_missing
86
-
87
- @property
88
- def is_numeric(self) -> bool:
89
- return all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in self.non_missing)
90
-
91
- def to_field_stats(self) -> FieldStats:
92
- if self.is_numeric:
93
- return NumericFieldStats(
94
- field=self.field,
95
- count=self.num_non_missing,
96
- missing=self.missing,
97
- mean=statistics.fmean(self.non_missing),
98
- min=min(self.non_missing),
99
- max=max(self.non_missing),
100
- stdev=statistics.stdev(self.non_missing) if self.num_non_missing > 1 else 0.0,
101
- )
102
-
103
- counts = Counter(self.non_missing)
104
- top_value, top_count = counts.most_common(1)[0] if counts else (None, 0)
105
- return CategoricalFieldStats(
106
- field=self.field,
107
- count=self.num_non_missing,
108
- missing=self.missing,
109
- unique=len(counts),
110
- top=top_value,
111
- freq=top_count,
112
- )
113
-
114
- def add(self, value: Any) -> None:
115
- self.values.append(value)
116
- self.num_values += 1
117
- if value is not None:
118
- self.non_missing.append(value)
119
- self.num_non_missing += 1
120
-
121
-
122
- class DatasetStats:
123
- """
124
- Read-only field statistics for a Dataset's current records.
125
-
126
- Computed fresh on every call from the dataset's public interface (never
127
- cached), so there's nothing here that can go stale after a mutation --
128
- the same reasoning that ruled out persistent secondary indexes applies:
129
- recomputing is cheap and always correct, caching is not free and can lie.
130
- """
131
-
132
- def __init__(self, data: list[Any], schema: tuple[Any, ...] | None = None) -> None:
133
- self._data = data
134
- self._schema = schema or ()
135
-
136
- def value_counts(self, field: Any) -> ValueCounts:
137
- """
138
- How often each distinct value of `field` occurs, most frequent first
139
- """
140
- counts = dict(Counter(rec[field] for rec in self._data).most_common())
141
- return ValueCounts(field=field, counts=counts)
142
-
143
- def describe(self, field: Any | None = None) -> dict[Any, FieldStats]:
144
- """
145
- Summary statistics per field, pandas-.describe()-style.
146
-
147
- Numeric fields (int/float, excluding bool) get a NumericFieldStats
148
- (mean/min/max/stdev). Everything else gets a CategoricalFieldStats
149
- (unique/top/freq). Pass `field` to describe just one; omit to
150
- describe every field in the schema.
151
- """
152
- fields = (field,) if field is not None else self._schema
153
- stats = {}
154
- for f in fields:
155
- field_description = FieldDescription.from_data(self._data, f)
156
- stats[f] = field_description.to_field_stats()
157
- return stats
1
+ import statistics
2
+ from collections import Counter
3
+ from dataclasses import dataclass
4
+ from dataclasses import field as dataclass_field
5
+ from typing import Any, Iterator
6
+
7
+ from corekit.utils.coercion import safe_tuple
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class FieldStats:
12
+ """
13
+ Common shape shared by every field's statistics
14
+ """
15
+
16
+ field: Any
17
+ count: int
18
+ missing: int
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class NumericFieldStats(FieldStats):
23
+ """
24
+ Stats for a field where every non-missing value is int/float (not bool)
25
+ """
26
+
27
+ mean: float
28
+ min: Any
29
+ max: Any
30
+ stdev: float
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class CategoricalFieldStats(FieldStats):
35
+ """
36
+ Stats for any field that isn't purely numeric
37
+ """
38
+
39
+ unique: int
40
+ top: Any
41
+ freq: int
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class ValueCounts:
46
+ """
47
+ How often each distinct value of a field occurs, most frequent first
48
+ """
49
+
50
+ field: Any
51
+ counts: dict[Any, int]
52
+
53
+ def most_common(self, n: int | None = None) -> list[tuple[Any, int]]:
54
+ items = list(self.counts.items())
55
+ return items[:n] if n is not None else items
56
+
57
+ def top(self) -> tuple[Any, int] | None:
58
+ return next(iter(self.counts.items()), None)
59
+
60
+ def __iter__(self) -> Iterator[tuple[Any, int]]:
61
+ return iter(self.counts.items())
62
+
63
+ def __len__(self) -> int:
64
+ return len(self.counts)
65
+
66
+ def __getitem__(self, value: Any) -> int:
67
+ return self.counts[value]
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class FieldDescription:
72
+ field: Any
73
+ values: list[Any] = dataclass_field(default_factory=list)
74
+ non_missing: list[Any] = dataclass_field(default_factory=list)
75
+ num_values: int = dataclass_field(default=0)
76
+ num_non_missing: int = dataclass_field(default=0)
77
+
78
+ @classmethod
79
+ def from_data(cls, data: list[Any], field: Any) -> "FieldDescription":
80
+ instance = cls(field)
81
+ for record in data:
82
+ instance.add(record[field])
83
+ return instance
84
+
85
+ @property
86
+ def missing(self) -> int:
87
+ return self.num_values - self.num_non_missing
88
+
89
+ @property
90
+ def is_numeric(self) -> bool:
91
+ return all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in self.non_missing)
92
+
93
+ def to_field_stats(self) -> FieldStats:
94
+ if self.is_numeric:
95
+ return NumericFieldStats(
96
+ field=self.field,
97
+ count=self.num_non_missing,
98
+ missing=self.missing,
99
+ mean=statistics.fmean(self.non_missing),
100
+ min=min(self.non_missing),
101
+ max=max(self.non_missing),
102
+ stdev=statistics.stdev(self.non_missing) if self.num_non_missing > 1 else 0.0,
103
+ )
104
+
105
+ counts = Counter(self.non_missing)
106
+ top_value, top_count = counts.most_common(1)[0] if counts else (None, 0)
107
+ return CategoricalFieldStats(
108
+ field=self.field,
109
+ count=self.num_non_missing,
110
+ missing=self.missing,
111
+ unique=len(counts),
112
+ top=top_value,
113
+ freq=top_count,
114
+ )
115
+
116
+ def add(self, value: Any) -> None:
117
+ self.values.append(value)
118
+ self.num_values += 1
119
+ if value is not None:
120
+ self.non_missing.append(value)
121
+ self.num_non_missing += 1
122
+
123
+
124
+ class DatasetStats:
125
+ """
126
+ Read-only field statistics for a Dataset's current records.
127
+
128
+ Computed fresh on every call from the dataset's public interface (never
129
+ cached), so there's nothing here that can go stale after a mutation --
130
+ the same reasoning that ruled out persistent secondary indexes applies:
131
+ recomputing is cheap and always correct, caching is not free and can lie.
132
+ """
133
+
134
+ def __init__(self, data: list[Any], schema: tuple[Any, ...] | None = None) -> None:
135
+ self._data = data
136
+ self._schema = safe_tuple(schema)
137
+
138
+ def value_counts(self, field: Any) -> ValueCounts:
139
+ """
140
+ How often each distinct value of `field` occurs, most frequent first
141
+ """
142
+ counts = dict(Counter(rec[field] for rec in self._data).most_common())
143
+ return ValueCounts(field=field, counts=counts)
144
+
145
+ def describe(self, field: Any | None = None) -> dict[Any, FieldStats]:
146
+ """
147
+ Summary statistics per field, pandas-.describe()-style.
148
+
149
+ Numeric fields (int/float, excluding bool) get a NumericFieldStats
150
+ (mean/min/max/stdev). Everything else gets a CategoricalFieldStats
151
+ (unique/top/freq). Pass `field` to describe just one; omit to
152
+ describe every field in the schema.
153
+ """
154
+ fields = (field,) if field is not None else self._schema
155
+ stats = {}
156
+ for f in fields:
157
+ field_description = FieldDescription.from_data(self._data, f)
158
+ stats[f] = field_description.to_field_stats()
159
+ return stats
@@ -1,2 +1,2 @@
1
- from .exception_handling import exception_handler
2
- from .warnings import deprecated
1
+ from .exception_handling import exception_handler
2
+ from .warnings import deprecated
@@ -3,6 +3,7 @@ import logging
3
3
  from typing import Any, Callable
4
4
 
5
5
  from corekit.utils import raise_exc, void
6
+ from corekit.utils.coercion import safe_tuple
6
7
 
7
8
  logger = logging.getLogger(__name__)
8
9
 
@@ -20,7 +21,7 @@ def exception_handler(
20
21
  callback: called when an exception is raised that is not ignored.
21
22
  ignore_callback: called when a suppressed exception is raised.
22
23
  """
23
- exceptions_to_ignore = tuple(ignore) if ignore else tuple()
24
+ exceptions_to_ignore = safe_tuple(ignore)
24
25
 
25
26
  def decorator(func):
26
27
  @functools.wraps(func)
corekit/etl/connection.py CHANGED
@@ -1,44 +1,44 @@
1
- from enum import Enum
2
- from typing import NamedTuple
3
-
4
-
5
- class ConnectionDetails(NamedTuple):
6
- name: str
7
- description: str
8
-
9
-
10
- class BaseConnection(Enum):
11
- """
12
- Base enum for ETL Connections. All ETL connections must inherit from this class.
13
-
14
- Example:
15
- class MyConnectionEnum(BaseConnection):
16
- MY_FIRST_CONNECTION = ConnectionDetails(
17
- name="my_first_connection",
18
- description="My First Connection"
19
- )
20
- ANOTHER_CONNECTION = ConnectionDetails(
21
- name="another_connection",
22
- description="Another Connection"
23
- )
24
- """
25
-
26
- @classmethod
27
- def from_name(cls, name: str) -> "BaseConnection":
28
- # TODO: Make sure this is efficient
29
- for connection in cls:
30
- if connection.value.name == name:
31
- return connection
32
- raise ValueError(f"Connection {name} not found")
33
-
34
- def get_name(self) -> str:
35
- """
36
- Helper method for fetching the name of the connection
37
- """
38
- return self.value.name
39
-
40
- def get_description(self) -> str:
41
- """
42
- Helper method for fetching the name of the connection
43
- """
44
- return self.value.description
1
+ from enum import Enum
2
+ from typing import NamedTuple
3
+
4
+
5
+ class ConnectionDetails(NamedTuple):
6
+ name: str
7
+ description: str
8
+
9
+
10
+ class BaseConnection(Enum):
11
+ """
12
+ Base enum for ETL Connections. All ETL connections must inherit from this class.
13
+
14
+ Example:
15
+ class MyConnectionEnum(BaseConnection):
16
+ MY_FIRST_CONNECTION = ConnectionDetails(
17
+ name="my_first_connection",
18
+ description="My First Connection"
19
+ )
20
+ ANOTHER_CONNECTION = ConnectionDetails(
21
+ name="another_connection",
22
+ description="Another Connection"
23
+ )
24
+ """
25
+
26
+ @classmethod
27
+ def from_name(cls, name: str) -> "BaseConnection":
28
+ # TODO: Make sure this is efficient
29
+ for connection in cls:
30
+ if connection.value.name == name:
31
+ return connection
32
+ raise ValueError(f"Connection {name} not found")
33
+
34
+ def get_name(self) -> str:
35
+ """
36
+ Helper method for fetching the name of the connection
37
+ """
38
+ return self.value.name
39
+
40
+ def get_description(self) -> str:
41
+ """
42
+ Helper method for fetching the name of the connection
43
+ """
44
+ return self.value.description
@@ -13,6 +13,7 @@ from typing import Any
13
13
  from corekit.events.enum import MessageField
14
14
  from corekit.events.reader import RedisChannelReader
15
15
  from corekit.observability.loggable import Loggable
16
+ from corekit.utils.coercion import safe_dict, safe_list
16
17
 
17
18
  __all__ = ["WebSocketBridge"]
18
19
 
@@ -44,7 +45,7 @@ class WebSocketBridge(Loggable):
44
45
  super().__init__()
45
46
  self.channel = channel
46
47
  self.websocket = websocket
47
- self.terminal_statuses = terminal_statuses or []
48
+ self.terminal_statuses = safe_list(terminal_statuses)
48
49
  self.timeout = timeout
49
50
  self._reader = RedisChannelReader(channel, url=url)
50
51
 
@@ -56,7 +57,7 @@ class WebSocketBridge(Loggable):
56
57
  return False
57
58
  if payload.get(MessageField.TYPE.value) != STATUS_UPDATE_EVENT:
58
59
  return False
59
- data = payload.get(MessageField.DATA.value) or {}
60
+ data = safe_dict(payload.get(MessageField.DATA.value))
60
61
  return data.get(MessageField.STATUS.value) in self.terminal_statuses
61
62
 
62
63
  def _expired(self, started: float) -> bool:
@@ -0,0 +1,18 @@
1
+ """
2
+ Exception base classes.
3
+
4
+ ``CustomException`` carries a message and an optional underlying error;
5
+ ``CustomHTTPException`` is its FastAPI-facing counterpart.
6
+ """
7
+
8
+ from corekit.exceptions.base import CustomException, CustomHTTPException, ExponentialBackoffTimeoutException
9
+ from corekit.exceptions.types import ArbitraryCustomExceptionType, CustomExceptionType, CustomHTTPExceptionType
10
+
11
+ __all__ = [
12
+ "ArbitraryCustomExceptionType",
13
+ "CustomException",
14
+ "CustomExceptionType",
15
+ "CustomHTTPException",
16
+ "CustomHTTPExceptionType",
17
+ "ExponentialBackoffTimeoutException",
18
+ ]
corekit/http/__init__.py CHANGED
@@ -0,0 +1,13 @@
1
+ """
2
+ HTTP client building blocks.
3
+
4
+ ``BaseApiClient`` wraps httpx with retries; ``ExponentialBackoff`` is the retry
5
+ policy behind it and is useful on its own for any operation that should back off
6
+ rather than hammer.
7
+ """
8
+
9
+ from corekit.http.client import BaseApiClient, URLMismatchError
10
+ from corekit.http.exponential_backoff import ExponentialBackoff
11
+ from corekit.http.response import BaseApiResponse
12
+
13
+ __all__ = ["BaseApiClient", "BaseApiResponse", "ExponentialBackoff", "URLMismatchError"]
@@ -0,0 +1,26 @@
1
+ """
2
+ Background tasks, independent of any queue.
3
+
4
+ Define a task by subclassing ``Task`` or ``ScheduledTask``; it registers itself
5
+ by name when the class is defined. A worker calls ``run_task(name, payload)``,
6
+ which builds the task from its own registry and runs it.
7
+
8
+ Nothing here imports a queue library. Bind these to RQ, Celery or anything else
9
+ with a small adapter, and keep the tasks themselves portable.
10
+
11
+ Arguments cross the queue as JSON, never as a serialized object: pickle and dill
12
+ execute code while loading, so a queue holding objects turns write access into
13
+ code execution in a worker.
14
+ """
15
+
16
+ from corekit.jobs.registry import TaskNotRegisteredError, task_registry
17
+ from corekit.jobs.runner import run_task
18
+ from corekit.jobs.task import ScheduledTask, Task
19
+
20
+ __all__ = [
21
+ "ScheduledTask",
22
+ "Task",
23
+ "TaskNotRegisteredError",
24
+ "run_task",
25
+ "task_registry",
26
+ ]
@@ -0,0 +1,87 @@
1
+ """
2
+ The task registry.
3
+
4
+ A worker rebuilds a task from a name, so the name has to resolve to a class in
5
+ the worker's *own* code. That is the whole security property: the queue chooses
6
+ which registered task runs, never what code exists.
7
+
8
+ ``SmartRegistry`` normalizes keys, so ``"NightlyBackup"``, ``"nightly_backup"``
9
+ and ``"nightly backup"`` all address the same task.
10
+ """
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from corekit.registry import SmartRegistry
15
+
16
+ if TYPE_CHECKING:
17
+ from corekit.jobs.task import Task
18
+
19
+ __all__ = ["TaskNotRegisteredError", "task_registry"]
20
+
21
+
22
+ class TaskNotRegisteredError(LookupError):
23
+ """
24
+ Raised when a queued name matches no registered task.
25
+ """
26
+
27
+
28
+ class _TaskRegistry:
29
+ """
30
+ Every concrete ``Task`` subclass, keyed by name.
31
+
32
+ Registration happens when a subclass is defined, so importing the module
33
+ that declares a task is what makes it runnable. A worker must therefore
34
+ import its tasks before it starts consuming the queue.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ self._registry = SmartRegistry()
39
+
40
+ def register(self, task_class: type["Task"]) -> None:
41
+ """
42
+ Register a task class under its name.
43
+ """
44
+ self._registry[task_class.task_name or task_class.__name__] = task_class
45
+
46
+ def get(self, name: str) -> type["Task"]:
47
+ """
48
+ Look up a task class by name.
49
+
50
+ Raises:
51
+ TaskNotRegisteredError: If nothing is registered under that name.
52
+ Usually means the module declaring the task was never imported
53
+ by this worker.
54
+ """
55
+ task_class = self._registry.get(name)
56
+ if task_class is None:
57
+ known = ", ".join(sorted(self._registry.keys())) or "none"
58
+ raise TaskNotRegisteredError(
59
+ f"No task registered as '{name}'. Import the module that defines it before "
60
+ f"running the worker. Registered: {known}."
61
+ )
62
+ return task_class
63
+
64
+ def build(self, name: str) -> "Task":
65
+ """
66
+ Construct the task registered under ``name``.
67
+ """
68
+ return self.get(name)()
69
+
70
+ def __contains__(self, name: str) -> bool:
71
+ return name in self._registry
72
+
73
+ def __len__(self) -> int:
74
+ return len(self._registry)
75
+
76
+ def __iter__(self) -> Any:
77
+ return iter(self._registry)
78
+
79
+ @property
80
+ def names(self) -> list[str]:
81
+ """
82
+ The registered task names, normalized and sorted.
83
+ """
84
+ return sorted(self._registry.keys())
85
+
86
+
87
+ task_registry = _TaskRegistry()