python-corekit 0.1.1__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 (109) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +275 -0
  3. corekit/api/lifespan.py +233 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/__init__.py +2 -2
  7. corekit/concurrency/decorators.py +32 -5
  8. corekit/concurrency/thread_local.py +2 -2
  9. corekit/concurrency/worker.py +74 -65
  10. corekit/config/loader.py +42 -5
  11. corekit/config/settings.py +11 -1
  12. corekit/connections/__init__.py +7 -1
  13. corekit/connections/connectable.py +45 -4
  14. corekit/connections/redis/connection.py +53 -10
  15. corekit/connections/sql/__init__.py +33 -4
  16. corekit/connections/sql/connection.py +56 -3
  17. corekit/connections/sql/fields/__init__.py +2 -2
  18. corekit/connections/sql/fields/jsonb.py +13 -6
  19. corekit/connections/sql/migration/__init__.py +9 -5
  20. corekit/connections/sql/migration/base.py +3 -3
  21. corekit/connections/sql/migration/operations.py +135 -44
  22. corekit/connections/sql/migration/registry.py +2 -2
  23. corekit/connections/sql/operations/__init__.py +24 -0
  24. corekit/connections/sql/operations/base.py +111 -0
  25. corekit/connections/sql/operations/statements.py +170 -0
  26. corekit/connections/sql/query.py +4 -62
  27. corekit/connections/sql/table.py +33 -29
  28. corekit/crypto/__init__.py +3 -1
  29. corekit/crypto/constants.py +2 -2
  30. corekit/crypto/hasher.py +9 -4
  31. corekit/data/__init__.py +8 -0
  32. corekit/data/dataset.py +8 -2
  33. corekit/data/expressions/__init__.py +10 -2
  34. corekit/data/expressions/comparison.py +142 -123
  35. corekit/data/expressions/expression.py +71 -98
  36. corekit/data/expressions/operator.py +39 -0
  37. corekit/data/expressions/target.py +21 -0
  38. corekit/data/record.py +147 -147
  39. corekit/data/stats.py +162 -157
  40. corekit/decorators/__init__.py +2 -2
  41. corekit/decorators/exception_handling.py +38 -9
  42. corekit/docker/watchdog.py +50 -31
  43. corekit/etl/__init__.py +2 -1
  44. corekit/etl/connection.py +46 -44
  45. corekit/etl/extract/extractor.py +6 -13
  46. corekit/etl/orchestrator.py +19 -2
  47. corekit/etl/schemas.py +2 -2
  48. corekit/etl/transform/transformer.py +4 -1
  49. corekit/events/publisher.py +1 -1
  50. corekit/events/reader.py +26 -21
  51. corekit/events/sse.py +4 -1
  52. corekit/events/websocket.py +27 -13
  53. corekit/exceptions/__init__.py +33 -0
  54. corekit/exceptions/base.py +139 -10
  55. corekit/exceptions/enum.py +17 -0
  56. corekit/exceptions/types.py +6 -6
  57. corekit/files/__init__.py +2 -4
  58. corekit/files/base.py +15 -2
  59. corekit/files/enum.py +0 -5
  60. corekit/files/json.py +16 -2
  61. corekit/http/__init__.py +51 -0
  62. corekit/http/api.py +24 -0
  63. corekit/http/client.py +100 -73
  64. corekit/http/exceptions.py +140 -0
  65. corekit/http/response.py +50 -1
  66. corekit/http/status.py +89 -0
  67. corekit/jobs/__init__.py +26 -0
  68. corekit/jobs/registry.py +87 -0
  69. corekit/jobs/runner.py +80 -0
  70. corekit/jobs/task.py +173 -0
  71. corekit/log_monitor/models.py +8 -2
  72. corekit/log_monitor/service.py +77 -38
  73. corekit/notifications/base.py +18 -10
  74. corekit/observability/__init__.py +12 -3
  75. corekit/observability/benchmarkable.py +23 -5
  76. corekit/observability/loggable.py +21 -0
  77. corekit/observability/request_context.py +188 -0
  78. corekit/observability/timing/timer.py +4 -2
  79. corekit/registry/__init__.py +12 -7
  80. corekit/registry/ordered.py +86 -0
  81. corekit/registry/registry.py +55 -14
  82. corekit/schemas/__init__.py +10 -0
  83. corekit/schemas/enum.py +70 -49
  84. corekit/schemas/models/arbitrary.py +11 -11
  85. corekit/schemas/pydantic/fields.py +35 -35
  86. corekit/schemas/types.py +45 -40
  87. corekit/serialization/__init__.py +24 -0
  88. corekit/serialization/pickle_file.py +61 -0
  89. corekit/serialization/serializable.py +22 -2
  90. corekit/serialization/serializer.py +10 -3
  91. corekit/utils/__init__.py +59 -5
  92. corekit/utils/coercion.py +118 -0
  93. corekit/utils/collections.py +124 -0
  94. corekit/utils/ids.py +61 -5
  95. corekit/utils/payload.py +112 -0
  96. corekit/utils/raise_exc.py +8 -8
  97. corekit/utils/text.py +56 -0
  98. corekit/utils/time.py +74 -21
  99. corekit/utils/validators.py +15 -15
  100. corekit/utils/void.py +8 -8
  101. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
  102. python_corekit-0.3.0.dist-info/RECORD +145 -0
  103. corekit/constants.py +0 -45
  104. corekit/exceptions/http/exceptions.py +0 -37
  105. corekit/files/pickle.py +0 -12
  106. python_corekit-0.1.1.dist-info/RECORD +0 -125
  107. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  108. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  109. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
@@ -11,6 +11,7 @@ per container so a crash loop cannot become a restart loop.
11
11
  """
12
12
 
13
13
  import re
14
+ import shlex
14
15
  import signal
15
16
  import subprocess
16
17
  import threading
@@ -51,7 +52,7 @@ class LogMonitor(Watchdog):
51
52
  config_path: str | None = None,
52
53
  config: LogMonitorConfig | None = None,
53
54
  notification_service: BaseNotificationService | None = None,
54
- enforce_label: bool = False,
55
+ enforce_label: bool = True,
55
56
  max_workers: int | None = None,
56
57
  docker_host: str | None = None,
57
58
  handle_signals: bool = False,
@@ -61,6 +62,7 @@ class LogMonitor(Watchdog):
61
62
  :param config: an already-built configuration, instead of a path.
62
63
  :param notification_service: where notifications go; logs by default.
63
64
  :param enforce_label: only act on containers carrying the managed label.
65
+ On by default, matching Watchdog.
64
66
  :param handle_signals: install SIGTERM and SIGINT handlers. Off by
65
67
  default because installing them is process-global and only the
66
68
  program's entry point should decide that; ``run()`` turns it on.
@@ -117,12 +119,31 @@ class LogMonitor(Watchdog):
117
119
  }
118
120
  return mapping.get(severity, NotificationType.INFO)
119
121
 
120
- def _send_notification(self, message: str, severity: Severity) -> None:
121
- """Send notification using the notification service"""
122
+ def _send_notification(
123
+ self,
124
+ message: str,
125
+ severity: Severity,
126
+ container_name: str,
127
+ throttle: float = 0,
128
+ ) -> bool:
129
+ """
130
+ Send one notification for a container, honoring its throttle.
131
+
132
+ This is the only delivery path. A YAML notify action and a rule's
133
+ ``send_notification`` flag both come through here, so the throttle
134
+ cannot be recorded in one place and ignored in another.
135
+ """
136
+ last_notify = self.last_notify_times.get(container_name, 0.0)
137
+ if throttle and time.time() - last_notify < throttle:
138
+ self.debug(f"Notification throttled for {container_name}")
139
+ return False
140
+
141
+ self.last_notify_times[container_name] = time.time()
122
142
  notification = Notification(
123
143
  message=message, type=self._severity_to_notification_type(severity), meta={"source": "LogMonitor"}
124
144
  )
125
145
  self._notification_service.notify(notification)
146
+ return True
126
147
 
127
148
  def check_rate_limit(self, action_type: ActionType) -> bool:
128
149
  """Check if action is within rate limits"""
@@ -145,14 +166,17 @@ class LogMonitor(Watchdog):
145
166
  message = action.message.format(container=container_name, text=matched_text)
146
167
  self.info(f"[LogAction] {message}")
147
168
 
148
- def _execute_notify_action(self, action: NotifyAction, container_name: str) -> None:
149
- """Execute notify action - check throttle only (actual notification sent separately)"""
150
- # Check throttle
151
- last_notify = self.last_notify_times.get(container_name, 0.0)
152
- if time.time() - last_notify < action.throttle:
153
- self.debug(f"Notification throttled for {container_name}")
154
- return
155
- self.last_notify_times[container_name] = time.time()
169
+ def _execute_notify_action(
170
+ self,
171
+ action: NotifyAction,
172
+ container_name: str,
173
+ matched_text: str,
174
+ severity: Severity,
175
+ rule_name: str,
176
+ ) -> None:
177
+ """Deliver a notification, applying this action's throttle."""
178
+ message = f"*{rule_name}* in `{container_name}`\n```{matched_text.strip()}```"
179
+ self._send_notification(message, severity, container_name, throttle=action.throttle)
156
180
 
157
181
  def _execute_restart_action(self, action: RestartContainerAction, container_name: str) -> None:
158
182
  """Execute restart action - delegate to Watchdog parent"""
@@ -172,42 +196,51 @@ class LogMonitor(Watchdog):
172
196
  self.info(f"Waiting {action.delay}s before restarting {container_name}")
173
197
  time.sleep(action.delay)
174
198
 
175
- try:
176
- # Delegate to parent Watchdog method
177
- container = self._client.containers.get(container_name)
178
- container.restart()
199
+ if self.restart_container_by_name(container_name):
179
200
  self.restart_counts[container_name].append(time.time())
180
- self.info(f"Restarted container: {container_name}")
181
- except NotFound:
182
- self.error(f"Container '{container_name}' not found")
183
- except APIError as e:
184
- self.error(f"Failed to restart {container_name}: {e}")
185
201
 
186
202
  def _execute_pause_action(self, action: PauseContainerAction, container_name: str) -> None:
187
203
  """Execute pause action - delegate to Watchdog parent"""
188
204
  self.pause_container_by_name(container_name)
189
205
 
190
206
  def _execute_command_action(self, action: ExecuteCommandAction, container_name: str) -> None:
191
- """Execute shell command action"""
192
- command = action.command.format(container=container_name)
207
+ """
208
+ Run a command as an argument list. The config string is not a shell.
209
+ """
210
+ try:
211
+ argv = [part.format(container=container_name) for part in shlex.split(action.command)]
212
+ except ValueError as exc:
213
+ self.error(f"Invalid command for {container_name}: {exc}")
214
+ return
215
+ if not argv:
216
+ self.error(f"Empty command for {container_name}")
217
+ return
193
218
  try:
194
219
  result = subprocess.run(
195
- command,
196
- shell=True,
220
+ argv,
221
+ shell=False,
197
222
  capture_output=True,
198
223
  text=True,
199
- timeout=30, # 30 second timeout for safety
224
+ timeout=30,
200
225
  )
226
+ rendered = " ".join(argv)
201
227
  if result.returncode == 0:
202
- self.info(f"Executed: {command}\nOutput: {result.stdout}")
228
+ self.info(f"Executed: {rendered}\nOutput: {result.stdout}")
203
229
  else:
204
- self.error(f"Command failed: {command}\nError: {result.stderr}")
230
+ self.error(f"Command failed: {rendered}\nError: {result.stderr}")
205
231
  except subprocess.TimeoutExpired:
206
- self.error(f"Command timed out: {command}")
232
+ self.error(f"Command timed out: {action.command}")
207
233
  except Exception as e:
208
234
  self.error(f"Failed to execute command: {e}")
209
235
 
210
- def execute_action(self, action: Action, container_name: str, matched_text: str = "") -> None:
236
+ def execute_action(
237
+ self,
238
+ action: Action,
239
+ container_name: str,
240
+ matched_text: str = "",
241
+ severity: Severity = Severity.INFO,
242
+ rule_name: str = "",
243
+ ) -> None:
211
244
  """Execute configured actions by delegating to specific action handlers"""
212
245
  action_type = action.type
213
246
 
@@ -221,7 +254,7 @@ class LogMonitor(Watchdog):
221
254
  if action_type is ActionType.LOG:
222
255
  self._execute_log_action(action, container_name, matched_text)
223
256
  elif action_type is ActionType.NOTIFY:
224
- self._execute_notify_action(action, container_name)
257
+ self._execute_notify_action(action, container_name, matched_text, severity, rule_name)
225
258
  elif action_type is ActionType.RESTART_CONTAINER:
226
259
  self._execute_restart_action(action, container_name)
227
260
  elif action_type is ActionType.PAUSE_CONTAINER:
@@ -246,14 +279,20 @@ class LogMonitor(Watchdog):
246
279
  else:
247
280
  self.info(log_message)
248
281
 
249
- # Execute actions
250
- for action in rule.actions:
251
- self.execute_action(action, container_name, line)
252
-
253
- # Send notification if configured
254
- if rule.send_notification:
255
- message = f"*{rule.name}* in `{container_name}`\n```{line.strip()}```"
256
- self._send_notification(message, rule.severity)
282
+ # A notify flag is the same delivery path as a notify action.
283
+ # Do not send beside it: that path used to ignore the throttle.
284
+ actions = list(rule.actions)
285
+ if rule.send_notification and not any(action.type is ActionType.NOTIFY for action in actions):
286
+ actions.append(NotifyAction())
287
+
288
+ for action in actions:
289
+ self.execute_action(
290
+ action,
291
+ container_name,
292
+ line,
293
+ severity=rule.severity,
294
+ rule_name=rule.name,
295
+ )
257
296
 
258
297
  # FIXME: does this actually make more sense being part of watchdog?
259
298
  def monitor_container(self, container_name: str, rules: list[Rule]) -> None:
@@ -8,13 +8,15 @@ Subclass ``BaseNotificationService`` and implement ``_send``::
8
8
  Sends notifications by email.
9
9
  '''
10
10
 
11
- def _send(self, message: str) -> None:
12
- smtp.send(message)
11
+ def _send(self, notification: Notification) -> None:
12
+ smtp.send(notification.message, extra=notification.meta)
13
13
 
14
14
  service.notify(Notification(message="disk full", type=NotificationType.ERROR))
15
15
 
16
- Override ``_send``, not ``send``: ``notify`` formats the message and calls
17
- ``_send``, so an override with any other name silently does nothing.
16
+ Override ``_send``, not ``notify``, if you only want to replace the transport.
17
+ ``notify`` hands the whole ``Notification`` to ``_send``, including ``meta``.
18
+ Events are a different job: they fan a payload out over Redis, they do not
19
+ alert a person.
18
20
  """
19
21
 
20
22
  from corekit.notifications.models import Notification
@@ -36,16 +38,22 @@ class BaseNotificationService(Loggable):
36
38
  """
37
39
  Render a notification as the text a transport will send.
38
40
  """
39
- return f"[{notification.type} Notification]: {notification.message}"
41
+ text = f"[{notification.type} Notification]: {notification.message}"
42
+ if notification.meta:
43
+ return f"{text} {notification.meta}"
44
+ return text
40
45
 
41
- def _send(self, message: str) -> None:
46
+ def _send(self, notification: Notification) -> None:
42
47
  """
43
- Deliver formatted text. Override this in a subclass.
48
+ Deliver a notification. Override this in a subclass.
49
+
50
+ The argument is the notification, not a preformatted string, so a
51
+ transport can use ``message``, ``type``, and ``meta``.
44
52
  """
45
- self.warning(message)
53
+ self.warning(self._format(notification))
46
54
 
47
55
  def notify(self, notification: Notification) -> None:
48
56
  """
49
- Format a notification and deliver it.
57
+ Hand a notification to the transport, including its meta.
50
58
  """
51
- self._send(self._format(notification))
59
+ self._send(notification)
@@ -2,8 +2,9 @@
2
2
  Logging and performance measurement.
3
3
 
4
4
  ``Loggable`` gives a class a logger named after itself; ``Benchmarkable`` adds
5
- split timing on top. They live together because they are the same concern --
6
- knowing what a running system is doing.
5
+ split timing on top. ``BaseRequestContext`` carries per-request metadata so a
6
+ log line can say which request it belongs to. They live together because they
7
+ are the same concern -- knowing what a running system is doing.
7
8
 
8
9
  class Importer(Benchmarkable):
9
10
  def run(self) -> None:
@@ -15,7 +16,15 @@ knowing what a running system is doing.
15
16
 
16
17
  from corekit.observability.benchmarkable import Benchmarkable
17
18
  from corekit.observability.loggable import Loggable
19
+ from corekit.observability.request_context import BaseRequestContext, RequestContextFilter
18
20
  from corekit.observability.timing.split import Split
19
21
  from corekit.observability.timing.timer import Timer
20
22
 
21
- __all__ = ["Benchmarkable", "Loggable", "Split", "Timer"]
23
+ __all__ = [
24
+ "BaseRequestContext",
25
+ "Benchmarkable",
26
+ "Loggable",
27
+ "RequestContextFilter",
28
+ "Split",
29
+ "Timer",
30
+ ]
@@ -1,12 +1,30 @@
1
+ from typing import Any
2
+
1
3
  from corekit.observability.loggable import Loggable
4
+ from corekit.observability.timing.split import Split
2
5
  from corekit.observability.timing.timer import Timer
3
6
 
4
7
 
5
8
  class Benchmarkable(Loggable):
6
- def __init__(self) -> None:
7
- super().__init__()
9
+ """
10
+ ``Loggable`` plus split timing.
11
+
12
+ ``__init__`` forwards ``*args``/``**kwargs`` the same way ``Loggable``
13
+ does, so a service can sit in a cooperative ``super()`` chain without
14
+ this class swallowing the arguments the next base expects.
15
+ """
16
+
17
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
18
+ super().__init__(*args, **kwargs)
8
19
  self._timing = Timer()
9
20
 
10
- def timing(self, split_name: str | None = None) -> None:
11
- split_message = str(self._timing.split(split_name=split_name))
12
- self.info(split_message)
21
+ def timing(self, split_name: str | None = None) -> Split:
22
+ """
23
+ Log the time since the last split and return it.
24
+
25
+ The log line is unchanged. The return value is for a caller that
26
+ wants the numbers without parsing that line.
27
+ """
28
+ split = self._timing.split(split_name=split_name)
29
+ self.info(str(split))
30
+ return split
@@ -1,6 +1,8 @@
1
1
  import logging
2
2
  from typing import Any
3
3
 
4
+ from corekit.observability.request_context import RequestContextFilter
5
+
4
6
 
5
7
  class Loggable:
6
8
  """
@@ -8,10 +10,16 @@ class Loggable:
8
10
 
9
11
  Accepts and ignores ``*args``/``**kwargs`` so it can sit anywhere in a
10
12
  cooperative ``super().__init__()`` chain.
13
+
14
+ The logger name is the class name, not ``module.Class``. Consuming
15
+ projects filter on that name; renaming it is a log-config change, not a
16
+ local cleanup. Request context is attached as record attributes instead,
17
+ so a formatter can include it without renaming the logger.
11
18
  """
12
19
 
13
20
  def __init__(self, *args: Any, **kwargs: Any) -> None:
14
21
  self.logger = logging.getLogger(self.__class__.__name__)
22
+ _attach_request_context(self.logger)
15
23
 
16
24
  def debug(self, message: str, **kwargs: Any) -> None:
17
25
  self.logger.debug(message, **kwargs)
@@ -27,3 +35,16 @@ class Loggable:
27
35
 
28
36
  def exception(self, message: str, **kwargs: Any) -> None:
29
37
  self.logger.exception(message, **kwargs)
38
+
39
+
40
+ def _attach_request_context(logger: logging.Logger) -> None:
41
+ """
42
+ Attach the request-context filter once per logger name.
43
+
44
+ Loggers are process-global and keyed by name, so every instance of a
45
+ class shares one. Adding the filter again would stamp the same fields
46
+ twice.
47
+ """
48
+ if any(isinstance(existing, RequestContextFilter) for existing in logger.filters):
49
+ return
50
+ logger.addFilter(RequestContextFilter())
@@ -0,0 +1,188 @@
1
+ """
2
+ Request-scoped metadata.
3
+
4
+ Small values that belong to one request -- which surface initiated it, a request
5
+ id, a correlation id -- carried in a ``ContextVar`` so they follow the work
6
+ rather than being threaded through every call signature.
7
+
8
+ A ``ContextVar`` rather than ``threading.local``: coroutines that ``await``
9
+ may resume on a different thread, and several tasks may share one, so
10
+ thread-local state either leaks between requests or vanishes mid-request.
11
+ Context variables follow the logical task instead.
12
+
13
+ This is not for connections or anything that must be closed. It holds
14
+ immutable-per-request facts; see ``ThreadLocalRegistry`` for resources with a
15
+ lifecycle.
16
+
17
+ Subclass it with the fields an application actually wants::
18
+
19
+ class RequestContext(BaseRequestContext):
20
+ surface: Surface
21
+ request_id: str | None = None
22
+
23
+ with RequestContext(surface=Surface.API).active():
24
+ ... # RequestContext.current() sees it
25
+
26
+ Each subclass gets its own storage, so one application's context cannot be read
27
+ through another's class.
28
+ """
29
+
30
+ import logging
31
+ from contextlib import contextmanager
32
+ from contextvars import ContextVar, Token
33
+ from typing import Any, ClassVar, Iterator, Self
34
+
35
+ from pydantic import BaseModel
36
+
37
+ __all__ = ["BaseRequestContext", "RequestContextFilter"]
38
+
39
+
40
+ class BaseRequestContext(BaseModel):
41
+ """
42
+ Base for request-scoped metadata carried in a ``ContextVar``.
43
+
44
+ Prefer ``active()`` or ``active_if_absent()`` over the manual
45
+ ``activate``/``deactivate`` pair: an early return or a raised exception
46
+ between them leaks the context into whatever runs next on that task.
47
+ """
48
+
49
+ #: Subclasses that own storage. The logging filter walks this rather than
50
+ #: guessing which application's context class is in use.
51
+ _known_subclasses: ClassVar[list[type["BaseRequestContext"]]] = []
52
+
53
+ def __init_subclass__(cls, **kwargs: Any) -> None:
54
+ """
55
+ Give every subclass its own ContextVar.
56
+ """
57
+ super().__init_subclass__(**kwargs)
58
+ cls._context_var = ContextVar(f"{cls.__module__}.{cls.__name__}", default=None)
59
+ if cls not in BaseRequestContext._known_subclasses:
60
+ BaseRequestContext._known_subclasses.append(cls)
61
+
62
+ @classmethod
63
+ def _var(cls) -> ContextVar[Any]:
64
+ """
65
+ This class's context variable.
66
+
67
+ Raises:
68
+ TypeError: If called on ``BaseRequestContext`` itself, which holds
69
+ no storage -- subclass it with the fields you need.
70
+ """
71
+ # Set by __init_subclass__, so each subclass reads and writes its own
72
+ # variable rather than sharing one with its siblings.
73
+ var = cls.__dict__.get("_context_var") or getattr(cls, "_context_var", None)
74
+ if not isinstance(var, ContextVar):
75
+ raise TypeError(
76
+ f"{cls.__name__} has no context storage. Subclass BaseRequestContext "
77
+ f"with the fields your application needs rather than using it directly."
78
+ )
79
+ return var
80
+
81
+ @classmethod
82
+ def current(cls) -> Self | None:
83
+ """
84
+ The active context, or ``None`` if nothing is active.
85
+ """
86
+ return cls._var().get()
87
+
88
+ def activate(self) -> Token:
89
+ """
90
+ Make this the active context, returning a token for ``deactivate``.
91
+
92
+ Prefer ``active()``, which cannot leak.
93
+ """
94
+ return self._var().set(self)
95
+
96
+ def activate_if_absent(self) -> Token | None:
97
+ """
98
+ Activate only if nothing is active yet, else return ``None``.
99
+
100
+ Lets an inner call establish a default without overriding the surface an
101
+ outer caller already set. The result, ``None`` included, goes to
102
+ ``deactivate``.
103
+ """
104
+ if self._var().get() is not None:
105
+ return None
106
+ return self._var().set(self)
107
+
108
+ @classmethod
109
+ def deactivate(cls, token: Token | None) -> None:
110
+ """
111
+ Restore whatever was active before. ``None`` is a no-op.
112
+ """
113
+ if token is not None:
114
+ cls._var().reset(token)
115
+
116
+ @contextmanager
117
+ def active(self) -> Iterator[Self]:
118
+ """
119
+ Activate for the duration of the block, restoring on the way out.
120
+
121
+ Restores even when the block raises, which the manual pair does not do
122
+ unless every caller remembers the ``finally``.
123
+ """
124
+ token = self.activate()
125
+ try:
126
+ yield self
127
+ finally:
128
+ type(self).deactivate(token)
129
+
130
+ @contextmanager
131
+ def active_if_absent(self) -> Iterator[Self | None]:
132
+ """
133
+ Activate for the block only if nothing is active yet.
134
+
135
+ Yields the context that ends up active -- this one, or the one that was
136
+ already there -- so a caller can read it either way.
137
+ """
138
+ token = self.activate_if_absent()
139
+ try:
140
+ yield type(self).current()
141
+ finally:
142
+ type(self).deactivate(token)
143
+
144
+
145
+ class RequestContextFilter(logging.Filter):
146
+ """
147
+ Stamp the active request context onto each log record.
148
+
149
+ The message is left alone, so existing log lines and anything that
150
+ matches them stay stable. Fields land on the record for a formatter
151
+ that opts in: ``%(request_context)s`` is a compact summary, and
152
+ ``request_context_fields`` is the dumped model. Both are always set,
153
+ to an empty string and an empty dict when nothing is active, so a
154
+ format string that names them does not raise.
155
+ """
156
+
157
+ def filter(self, record: logging.LogRecord) -> bool:
158
+ fields: dict[str, Any] = {}
159
+ parts: list[str] = []
160
+ for cls in BaseRequestContext._known_subclasses:
161
+ current = cls.current()
162
+ if current is None:
163
+ continue
164
+ dumped = _dump_context(current)
165
+ if not dumped:
166
+ continue
167
+ fields[cls.__name__] = dumped
168
+ rendered = ", ".join(f"{key}={value}" for key, value in dumped.items())
169
+ parts.append(f"{cls.__name__}({rendered})")
170
+
171
+ record.request_context = " ".join(parts)
172
+ record.request_context_fields = fields
173
+ return True
174
+
175
+
176
+ def _dump_context(context: BaseRequestContext) -> dict[str, Any]:
177
+ """
178
+ JSON-safe fields, or stringified values if a field will not dump.
179
+ """
180
+ try:
181
+ dumped = context.model_dump(mode="json")
182
+ except Exception:
183
+ dumped = {key: getattr(context, key, None) for key in type(context).model_fields}
184
+ return {key: value if _is_log_safe(value) else str(value) for key, value in dumped.items()}
185
+
186
+
187
+ def _is_log_safe(value: Any) -> bool:
188
+ return value is None or isinstance(value, (str, int, float, bool))
@@ -6,7 +6,9 @@ from corekit.observability.timing.split import Split
6
6
 
7
7
  class Timer:
8
8
  def __init__(self, precision: int = DEFAULT_PRECISION) -> None:
9
- _time = time.time()
9
+ # perf_counter is monotonic. time.time() can step backwards, which
10
+ # makes a split look negative for no reason the caller can act on.
11
+ _time = time.perf_counter()
10
12
  self.start = _time
11
13
  self.latest = _time
12
14
  self.num = 0
@@ -17,7 +19,7 @@ class Timer:
17
19
  return round(value, self.precision)
18
20
 
19
21
  def split(self, split_name: str | None = None) -> Split:
20
- _time = time.time()
22
+ _time = time.perf_counter()
21
23
  self.num += 1
22
24
  split = Split(
23
25
  num=self.num,
@@ -1,12 +1,17 @@
1
1
  """
2
- Key-normalizing registry.
2
+ Registries.
3
3
 
4
- ``SmartRegistry`` folds case, trims whitespace and treats spaces and underscores
5
- as hyphens, so ``"AI Conversation Handler"``, ``"ai_conversation_handler"`` and
6
- ``"ai-conversation-handler"`` all address the same entry. That forgiveness
7
- matters wherever a human -- or a language model -- supplies the key.
4
+ ``SmartRegistry`` is keyed and forgiving: it folds case, trims whitespace and
5
+ treats spaces and underscores as hyphens, so ``"AI Conversation Handler"``,
6
+ ``"ai_conversation_handler"`` and ``"ai-conversation-handler"`` all address the
7
+ same entry. That matters wherever a human -- or a language model -- supplies
8
+ the key.
9
+
10
+ ``OrderedRegistry`` is unkeyed: it preserves registration order and allows
11
+ duplicates. Reach for it when position is what matters and no name is needed.
8
12
  """
9
13
 
10
- from corekit.registry.registry import SmartRegistry
14
+ from corekit.registry.ordered import OrderedRegistry
15
+ from corekit.registry.registry import SmartRegistry, normalize_key
11
16
 
12
- __all__ = ["SmartRegistry"]
17
+ __all__ = ["OrderedRegistry", "SmartRegistry", "normalize_key"]
@@ -0,0 +1,86 @@
1
+ """
2
+ Insertion-ordered registry.
3
+
4
+ Where ``SmartRegistry`` is a dict keyed by a forgiving name, ``OrderedRegistry``
5
+ is a list: it keeps what was registered, in the order it was registered, and
6
+ lets callers filter it. Use it when order is the point and names are not --
7
+ mount order, execution order, pipeline stages -- and when two entries may
8
+ legitimately share a name.
9
+ """
10
+
11
+ from typing import Callable, Generic, Iterator, TypeVar
12
+
13
+ __all__ = ["OrderedRegistry"]
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class OrderedRegistry(Generic[T]):
19
+ """
20
+ Everything registered in this process, in construction order.
21
+
22
+ Deliberately not keyed. A keyed registry silently evicts a collision, which
23
+ for order-sensitive collections means an entry vanishing with no error --
24
+ so duplicates are kept, and callers select with ``where`` instead.
25
+ """
26
+
27
+ __slots__ = ("_entries",)
28
+
29
+ def __init__(self) -> None:
30
+ """
31
+ Initialize an empty registry.
32
+ """
33
+ self._entries: list[T] = []
34
+
35
+ def register(self, entry: T) -> T:
36
+ """
37
+ Append an entry, returning it so this can be used inline.
38
+ """
39
+ self._entries.append(entry)
40
+ return entry
41
+
42
+ def where(self, predicate: Callable[[T], bool]) -> list[T]:
43
+ """
44
+ Return the entries matching ``predicate``, in registration order.
45
+ """
46
+ return [entry for entry in self._entries if predicate(entry)]
47
+
48
+ def clear(self) -> None:
49
+ """
50
+ Forget every entry. For tests that build more than one of whatever this holds.
51
+ """
52
+ self._entries.clear()
53
+
54
+ @property
55
+ def entries(self) -> list[T]:
56
+ """
57
+ A copy of the entries, in registration order.
58
+
59
+ A copy, so a caller iterating the result cannot be tripped up by
60
+ something registering mid-loop.
61
+ """
62
+ return list(self._entries)
63
+
64
+ def __len__(self) -> int:
65
+ """
66
+ Return the number of registered entries.
67
+ """
68
+ return len(self._entries)
69
+
70
+ def __iter__(self) -> Iterator[T]:
71
+ """
72
+ Iterate the entries in registration order.
73
+ """
74
+ return iter(self._entries)
75
+
76
+ def __contains__(self, entry: object) -> bool:
77
+ """
78
+ Check whether an entry has been registered, by identity or equality.
79
+ """
80
+ return entry in self._entries
81
+
82
+ def __repr__(self) -> str:
83
+ """
84
+ Return a formal representation of the registry.
85
+ """
86
+ return f"{type(self).__name__}({self._entries!r})"