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
@@ -0,0 +1,118 @@
1
+ """
2
+ Total conversions.
3
+
4
+ Each helper turns anything into its target type, falling back to that type's
5
+ empty value rather than raising. Use them at boundaries where the input is
6
+ genuinely unknown -- an API payload, a config value, a scraped field -- and a
7
+ crash would be worse than a default.
8
+
9
+ The tradeoff is real: ``safe_int("banana")`` is ``0``, indistinguishable from a
10
+ genuine zero. Where telling those apart matters, parse explicitly instead.
11
+ """
12
+
13
+ import json
14
+ from datetime import date, datetime
15
+ from typing import Any
16
+
17
+ from corekit.utils.text import list_to_english
18
+
19
+ __all__ = ["safe_dict", "safe_float", "safe_int", "safe_list", "safe_string", "safe_tuple"]
20
+
21
+
22
+ def safe_string(item: Any, pretty: bool = False) -> str:
23
+ """
24
+ Convert anything to a string, or ``""`` if there is no sensible rendering.
25
+
26
+ Collections become JSON by default. With ``pretty`` they become an English
27
+ list instead -- ``"a, b, c"``, or ``"key: value, ..."`` for a mapping --
28
+ which reads better in a notification than raw JSON.
29
+ """
30
+ if isinstance(item, bytes):
31
+ item = item.decode("utf-8", errors="ignore")
32
+ if isinstance(item, str):
33
+ return item.strip()
34
+ # bool is a subclass of int, so both land here and render as "True"/"1".
35
+ if isinstance(item, (int, float)):
36
+ return str(item)
37
+ if isinstance(item, (list, tuple, set, dict)):
38
+ if pretty:
39
+ if isinstance(item, dict):
40
+ return list_to_english([f"{key}: {value}" for key, value in item.items()])
41
+ return list_to_english([safe_string(element) for element in item])
42
+ return json.dumps(item)
43
+ if isinstance(item, (date, datetime)):
44
+ return item.isoformat()
45
+ return ""
46
+
47
+
48
+ def safe_int(item: Any) -> int:
49
+ """
50
+ Convert anything to an int, or ``0`` if it will not convert.
51
+ """
52
+ if isinstance(item, int):
53
+ return item
54
+ if isinstance(item, bytes):
55
+ item = item.decode("utf-8", errors="ignore")
56
+ if isinstance(item, (float, str)):
57
+ try:
58
+ return int(item)
59
+ except ValueError:
60
+ return 0
61
+ return 0
62
+
63
+
64
+ def safe_float(item: Any) -> float:
65
+ """
66
+ Convert anything to a float, or ``0.0`` if it will not convert.
67
+ """
68
+ if isinstance(item, float):
69
+ return item
70
+ if isinstance(item, bytes):
71
+ item = item.decode("utf-8", errors="ignore")
72
+ if isinstance(item, (int, str)):
73
+ try:
74
+ return float(item)
75
+ except ValueError:
76
+ return 0.0
77
+ return 0.0
78
+
79
+
80
+ def safe_list(item: Any) -> list[Any]:
81
+ """
82
+ Convert a collection to a list, or ``[]`` for anything else.
83
+
84
+ A mapping yields its values. A string is *not* treated as a collection --
85
+ exploding it into characters is almost never what the caller meant.
86
+ """
87
+ if isinstance(item, list):
88
+ return item
89
+ if isinstance(item, (tuple, set)):
90
+ return list(item)
91
+ if isinstance(item, dict):
92
+ return list(item.values())
93
+ return []
94
+
95
+
96
+ def safe_tuple(item: Any) -> tuple[Any, ...]:
97
+ """
98
+ Convert a collection to a tuple, or ``()`` for anything else.
99
+
100
+ A mapping yields its values. A string is *not* treated as a collection --
101
+ exploding it into characters is almost never what the caller meant.
102
+ """
103
+ if isinstance(item, tuple):
104
+ return item
105
+ if isinstance(item, (list, set)):
106
+ return tuple(item)
107
+ if isinstance(item, dict):
108
+ return tuple(item.values())
109
+ return ()
110
+
111
+
112
+ def safe_dict(item: Any) -> dict[str, Any]:
113
+ """
114
+ Return the mapping unchanged, or ``{}`` for anything else.
115
+ """
116
+ if isinstance(item, dict):
117
+ return item
118
+ return {}
@@ -0,0 +1,124 @@
1
+ """
2
+ Nested lookup and small collection helpers.
3
+
4
+ ``repeated_get`` walks a path into nested dictionaries without a pile of
5
+ ``.get()`` calls, and ``keygetter`` lets a step in that path match many keys at
6
+ once. Built for JSON from somewhere you do not control, where any level may be
7
+ missing or the wrong type.
8
+ """
9
+
10
+ from copy import deepcopy
11
+ from typing import Any, Callable
12
+
13
+ __all__ = ["MultiMatch", "keygetter", "repeated_get", "split_list"]
14
+
15
+ KEY_SEPARATOR = "."
16
+
17
+ # Distinct from None, which is a legitimate value to find at a key.
18
+ UNSET: Any = object()
19
+
20
+
21
+ def split_list(items: list[Any], index: int) -> tuple[list[Any], list[Any]]:
22
+ """
23
+ Split a list in two at ``index``. Out-of-range indices yield an empty side.
24
+ """
25
+ return items[:index], items[index:]
26
+
27
+
28
+ class MultiMatch(dict[str, Any]):
29
+ """
30
+ The result of a path step that matched more than one key.
31
+
32
+ A dict subclass, so it reads like the mapping it is, but a distinct type so
33
+ ``repeated_get`` can tell "several matches" from "one match that happened to
34
+ be a dict".
35
+ """
36
+
37
+
38
+ class keygetter:
39
+ """
40
+ A path step that selects keys by predicate rather than by name.
41
+
42
+ Use where key names are not known ahead of time::
43
+
44
+ repeated_get(payload, keys=["items", keygetter(lambda k: k.startswith("id_"))])
45
+
46
+ One match returns that value; several return a ``MultiMatch``.
47
+ """
48
+
49
+ def __init__(self, func: Callable[..., Any]) -> None:
50
+ """
51
+ Build a step from a predicate over key names.
52
+ """
53
+ self.func = func
54
+ self._keymap: MultiMatch = MultiMatch()
55
+
56
+ def call(self, data: dict[str, Any]) -> Any | MultiMatch:
57
+ """
58
+ Apply the predicate, returning the match, a ``MultiMatch``, or ``UNSET``.
59
+
60
+ The keymap is replaced on every call. The same instance can be a step
61
+ more than once in one path; keeping the previous matches would make
62
+ the second step see keys from the first.
63
+ """
64
+ matches = MultiMatch()
65
+ for key, value in data.items():
66
+ if self.func(key):
67
+ matches[key] = value
68
+ self._keymap = matches
69
+
70
+ if not matches:
71
+ return UNSET
72
+
73
+ if len(matches) == 1:
74
+ return next(iter(matches.values()))
75
+
76
+ return matches
77
+
78
+
79
+ def repeated_get(
80
+ data: dict[str, Any],
81
+ keys: list[str | keygetter] | None = None,
82
+ keypath: str | None = None,
83
+ separator: str = KEY_SEPARATOR,
84
+ fallback: Any = None,
85
+ ) -> Any | MultiMatch:
86
+ """
87
+ Follow a path into nested dictionaries, returning ``fallback`` if it breaks.
88
+
89
+ Give either ``keys`` (which may contain ``keygetter`` steps) or a dotted
90
+ ``keypath``. Any level that is missing, or is not a mapping, ends the walk
91
+ at ``fallback`` rather than raising.
92
+ """
93
+ if keys is None:
94
+ if keypath is None:
95
+ raise ValueError("repeated_get needs either keys or keypath")
96
+ keys = list(keypath.split(separator))
97
+
98
+ def _get_item(current: Any, key: str | keygetter) -> Any | MultiMatch:
99
+ if current is UNSET or not isinstance(current, dict):
100
+ return UNSET
101
+
102
+ if isinstance(key, keygetter):
103
+ return key.call(current)
104
+ if callable(key):
105
+ # A bare predicate is the same step as a keygetter. A reused
106
+ # instance still goes through call(), which owns the keymap.
107
+ return keygetter(key).call(current)
108
+
109
+ return current.get(key, UNSET)
110
+
111
+ # Copied so a keygetter's predicate cannot mutate the caller's data.
112
+ result = deepcopy(data)
113
+ for key in keys:
114
+ if isinstance(result, MultiMatch):
115
+ collected = MultiMatch()
116
+ for parent_key, branch in result.items():
117
+ child = _get_item(branch, key)
118
+ if child is not UNSET:
119
+ collected[parent_key] = child
120
+ result = collected if collected else UNSET
121
+ else:
122
+ result = _get_item(result, key)
123
+
124
+ return fallback if result is UNSET else result
corekit/utils/ids.py CHANGED
@@ -1,5 +1,61 @@
1
- import uuid
2
-
3
-
4
- def generate_uuid() -> str:
5
- return str(uuid.uuid4())
1
+ """
2
+ Identifier generation.
3
+
4
+ UUIDs for internal keys, URL-safe tokens for sessions, and short human-readable
5
+ codes for anything someone has to read aloud or type in by hand.
6
+ """
7
+
8
+ import secrets
9
+ import string
10
+ import uuid
11
+
12
+ __all__ = [
13
+ "SHORTCODE_CHARS",
14
+ "generate_session_token",
15
+ "generate_shortcode",
16
+ "generate_shortcodes",
17
+ "generate_uuid",
18
+ ]
19
+
20
+ # Uppercase alphanumerics minus the characters people confuse when reading a
21
+ # code off a screen: O/0 and I/1.
22
+ SHORTCODE_CHARS = string.ascii_uppercase.replace("O", "").replace("I", "") + string.digits.replace("0", "").replace(
23
+ "1", ""
24
+ )
25
+
26
+
27
+ def generate_uuid() -> str:
28
+ """
29
+ Return a random UUID4 as a string.
30
+ """
31
+ return str(uuid.uuid4())
32
+
33
+
34
+ def generate_session_token(length: int = 32) -> str:
35
+ """
36
+ Return a cryptographically random URL-safe token.
37
+
38
+ ``length`` is the number of random *bytes*; the returned string is longer,
39
+ since base64 encoding expands it by about a third.
40
+ """
41
+ return secrets.token_urlsafe(length)
42
+
43
+
44
+ def generate_shortcode(length: int, alphabet: str = SHORTCODE_CHARS) -> str:
45
+ """
46
+ Return a cryptographically random code of ``length`` characters.
47
+
48
+ Drawn from an unambiguous alphabet, so the result survives being read over
49
+ the phone or copied off a screen.
50
+ """
51
+ return "".join(secrets.choice(alphabet) for _ in range(length))
52
+
53
+
54
+ def generate_shortcodes(length: int, count: int, alphabet: str = SHORTCODE_CHARS) -> list[str]:
55
+ """
56
+ Return ``count`` shortcodes.
57
+
58
+ Codes are independent, so duplicates are possible; if they must be distinct,
59
+ check them against whatever store they are destined for.
60
+ """
61
+ return [generate_shortcode(length, alphabet) for _ in range(count)]
@@ -0,0 +1,112 @@
1
+ """
2
+ JSON argument payloads.
3
+
4
+ Encodes a call's arguments as JSON so they can cross a process boundary -- a
5
+ queue, a subprocess, a scheduler entry -- as data rather than as a serialized
6
+ object. ``pickle`` and ``dill`` execute code while loading, so a channel
7
+ carrying serialized objects turns "can write to the channel" into "can run code
8
+ on the other side". JSON cannot construct an arbitrary object, so whatever runs
9
+ always comes from the receiver's own code.
10
+
11
+ Arguments are validated when they are encoded rather than when they are used,
12
+ so a value that cannot cross fails at the call site where the offending
13
+ argument is still visible.
14
+ """
15
+
16
+ import base64
17
+ import json
18
+ from datetime import date, datetime
19
+ from decimal import Decimal
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import Any, NamedTuple
23
+ from uuid import UUID
24
+
25
+ __all__ = ["Payload", "decode_payload", "encode_payload"]
26
+
27
+
28
+ class Payload(NamedTuple):
29
+ """
30
+ The positional and keyword arguments a call was made with.
31
+
32
+ A tuple, so it still unpacks as ``args, kwargs``, but the fields are named
33
+ at the point they are read.
34
+ """
35
+
36
+ args: list[Any]
37
+ kwargs: dict[str, Any]
38
+
39
+
40
+ def encode_payload(args: tuple[Any, ...] | list[Any] = (), kwargs: dict[str, Any] | None = None) -> str:
41
+ """
42
+ Encode positional and keyword arguments as a JSON string.
43
+
44
+ Raises:
45
+ TypeError: If any argument is not JSON-serializable, naming the value.
46
+ Enums encode as their value and datetimes as ISO 8601, so those
47
+ cross as plain data; the receiver gets the encoded form, not the
48
+ original object.
49
+ """
50
+ try:
51
+ return json.dumps({"args": list(args), "kwargs": kwargs or {}}, default=_encode_extra)
52
+ except TypeError as exc:
53
+ raise TypeError(
54
+ f"Arguments must be JSON-serializable so they can cross as data: {exc}. "
55
+ f"Pass an id or another plain value and load the object on the other side."
56
+ ) from exc
57
+
58
+
59
+ def _encode_extra(value: Any) -> Any:
60
+ """
61
+ Encode the few non-JSON types that appear in ordinary arguments.
62
+
63
+ The receiver gets what comes back out -- ``"GET"`` rather than
64
+ ``HTTPMethod.GET`` -- so code wanting the original type reconstructs it.
65
+ Anything not handled here raises, which is the point: an object that cannot
66
+ cross as data has to be looked up on the other side instead.
67
+ """
68
+ if isinstance(value, Enum):
69
+ return value.value
70
+ if isinstance(value, (datetime, date)):
71
+ return value.isoformat()
72
+ if isinstance(value, UUID):
73
+ return str(value)
74
+ if isinstance(value, Path):
75
+ return str(value)
76
+ if isinstance(value, Decimal):
77
+ return str(value)
78
+ if isinstance(value, bytes):
79
+ return base64.b64encode(value).decode("ascii")
80
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
81
+
82
+
83
+ def decode_payload(payload: str | bytes | None) -> Payload:
84
+ """
85
+ Decode a payload produced by ``encode_payload``.
86
+
87
+ Returns:
88
+ The positional arguments and keyword arguments.
89
+
90
+ Raises:
91
+ ValueError: If the payload is not the expected JSON object.
92
+ """
93
+ if not payload:
94
+ return Payload(args=[], kwargs={})
95
+
96
+ try:
97
+ decoded = json.loads(payload)
98
+ except json.JSONDecodeError as exc:
99
+ raise ValueError(f"Payload is not valid JSON: {exc}") from exc
100
+
101
+ if not isinstance(decoded, dict):
102
+ raise ValueError(f"Payload must be a JSON object, got {type(decoded).__name__}")
103
+
104
+ args = decoded.get("args", [])
105
+ kwargs = decoded.get("kwargs", {})
106
+
107
+ if not isinstance(args, list):
108
+ raise ValueError(f"Payload 'args' must be a list, got {type(args).__name__}")
109
+ if not isinstance(kwargs, dict):
110
+ raise ValueError(f"Payload 'kwargs' must be an object, got {type(kwargs).__name__}")
111
+
112
+ return Payload(args=args, kwargs=kwargs)
@@ -1,8 +1,8 @@
1
- from typing import Any
2
-
3
-
4
- def raise_exc(ex: Exception, *args: Any, **kwargs: Any) -> None:
5
- """
6
- Generic callback function that raises the exception
7
- """
8
- raise ex
1
+ from typing import Any
2
+
3
+
4
+ def raise_exc(ex: Exception, *args: Any, **kwargs: Any) -> None:
5
+ """
6
+ Generic callback function that raises the exception
7
+ """
8
+ raise ex
corekit/utils/text.py ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ String assembly helpers.
3
+
4
+ Small formatters for turning collections into text a human will read -- log
5
+ lines, notification bodies, error messages -- plus filename sanitization.
6
+ """
7
+
8
+ import re
9
+ from typing import Any, Literal
10
+
11
+ __all__ = ["join_lines", "list_to_english", "sanitize_filename"]
12
+
13
+ FALLBACK_FILENAME = "unnamed"
14
+ _UNSAFE_CHARS = re.compile(r"[^a-z0-9\-]")
15
+ _REPEATED_HYPHENS = re.compile(r"-{2,}")
16
+
17
+
18
+ def join_lines(*items: Any, prefix: str = "") -> str:
19
+ """
20
+ Join items with newlines, optionally prefixing each one.
21
+ """
22
+ return "\n".join(f"{prefix}{item}" for item in items)
23
+
24
+
25
+ def list_to_english(items: list[str], ending: Literal["and", "or"] | None = None) -> str:
26
+ """
27
+ Join items into a readable list: ``"a, b, and c"``.
28
+
29
+ Without ``ending`` the final item is joined with a plain comma, which suits
30
+ a bare enumeration; pass ``"and"`` or ``"or"`` for prose.
31
+ """
32
+ if not items:
33
+ return ""
34
+
35
+ if len(items) == 1:
36
+ return items[0]
37
+
38
+ buffer = ", ".join(items[:-1])
39
+ last = items[-1]
40
+
41
+ if ending is None:
42
+ return f"{buffer}, {last}"
43
+ return f"{buffer}, {ending} {last}"
44
+
45
+
46
+ def sanitize_filename(raw: str, fallback: str = FALLBACK_FILENAME) -> str:
47
+ """
48
+ Reduce a string to lowercase kebab-case safe for use as a filename stem.
49
+
50
+ Everything outside ``a-z0-9-`` becomes a hyphen, runs collapse, and the
51
+ result is trimmed. A string with nothing usable in it returns ``fallback``
52
+ rather than the empty string, which would produce a nameless file.
53
+ """
54
+ cleaned = _UNSAFE_CHARS.sub("-", raw.strip().lower())
55
+ cleaned = _REPEATED_HYPHENS.sub("-", cleaned).strip("-")
56
+ return cleaned or fallback
corekit/utils/time.py CHANGED
@@ -1,21 +1,74 @@
1
- from datetime import UTC, datetime, timedelta
2
-
3
-
4
- def time_now() -> datetime:
5
- """
6
- Helper for getting the current UTC time
7
- """
8
- return datetime.now(UTC)
9
-
10
-
11
- def timedelta_now(delta_seconds: int | None = None, add_time: bool = False, **kwargs) -> datetime:
12
- """
13
- Helper for getting a quick timedelta
14
- """
15
- if delta_seconds is not None:
16
- kwargs = {"seconds": delta_seconds}
17
-
18
- delta = timedelta(**kwargs)
19
- if add_time:
20
- return time_now() + delta
21
- return time_now() - delta
1
+ """
2
+ Timezone-aware time helpers.
3
+
4
+ Every function returns UTC. Naive datetimes are the usual source of "works on
5
+ my machine" time bugs, so nothing here produces one.
6
+ """
7
+
8
+ import re
9
+ from datetime import UTC, datetime, timedelta
10
+ from typing import Any
11
+
12
+ __all__ = ["isoformat_now", "parse_docker_timestamp", "time_now", "timedelta_now", "timestamp_now"]
13
+
14
+ # Docker reports nanosecond precision; fromisoformat accepts at most microseconds.
15
+ _DOCKER_TS_PATTERN = re.compile(r"(\.\d{6})\d*")
16
+
17
+ # Docker's zero value for "this never happened".
18
+ _DOCKER_ZERO_TIME = "0001-01-01"
19
+
20
+
21
+ def time_now() -> datetime:
22
+ """
23
+ Return the current UTC time, timezone-aware.
24
+ """
25
+ return datetime.now(UTC)
26
+
27
+
28
+ def isoformat_now() -> str:
29
+ """
30
+ Return the current UTC time as an ISO 8601 string.
31
+ """
32
+ return time_now().isoformat()
33
+
34
+
35
+ def timestamp_now() -> float:
36
+ """
37
+ Return the current UTC time as a Unix timestamp.
38
+ """
39
+ return time_now().timestamp()
40
+
41
+
42
+ def timedelta_now(delta_seconds: int | None = None, add_time: bool = False, **kwargs: Any) -> datetime:
43
+ """
44
+ Return a time offset from now, in the past by default.
45
+
46
+ Pass ``delta_seconds`` for the common case, or any ``timedelta`` keyword
47
+ (``days``, ``hours``, ...) for the rest. ``add_time`` moves forward instead
48
+ of back.
49
+ """
50
+ if delta_seconds is not None:
51
+ kwargs = {"seconds": delta_seconds}
52
+
53
+ delta = timedelta(**kwargs)
54
+ if add_time:
55
+ return time_now() + delta
56
+ return time_now() - delta
57
+
58
+
59
+ def parse_docker_timestamp(value: str | None) -> float | None:
60
+ """
61
+ Parse a docker API timestamp into a Unix timestamp, or ``None``.
62
+
63
+ Docker emits nanosecond-precision ISO 8601 (a container's ``State.StartedAt``,
64
+ for instance), which ``fromisoformat`` rejects. Returns ``None`` for empty
65
+ input, docker's zero timestamp, and anything unparseable, so a caller can
66
+ treat "no such time" and "bad time" the same way.
67
+ """
68
+ if not value or value.startswith(_DOCKER_ZERO_TIME):
69
+ return None
70
+ try:
71
+ trimmed = _DOCKER_TS_PATTERN.sub(r"\1", value.replace("Z", "+00:00"))
72
+ return datetime.fromisoformat(trimmed).timestamp()
73
+ except ValueError:
74
+ return None
@@ -1,15 +1,15 @@
1
- from typing import Any
2
-
3
-
4
- def true_validator(*args: Any, **kwargs: Any) -> bool:
5
- """
6
- Generic placeholder validator function that always returns True
7
- """
8
- return True
9
-
10
-
11
- def false_validator(*args: Any, **kwargs: Any) -> bool:
12
- """
13
- Generic placeholder validator function that always returns False
14
- """
15
- return False
1
+ from typing import Any
2
+
3
+
4
+ def true_validator(*args: Any, **kwargs: Any) -> bool:
5
+ """
6
+ Generic placeholder validator function that always returns True
7
+ """
8
+ return True
9
+
10
+
11
+ def false_validator(*args: Any, **kwargs: Any) -> bool:
12
+ """
13
+ Generic placeholder validator function that always returns False
14
+ """
15
+ return False
corekit/utils/void.py CHANGED
@@ -1,8 +1,8 @@
1
- from typing import Any
2
-
3
-
4
- def void(ex: Exception, *args: Any, **kwargs: Any) -> None:
5
- """
6
- Generic callback function that does nothing and returns nothing
7
- """
8
- pass
1
+ from typing import Any
2
+
3
+
4
+ def void(ex: Exception, *args: Any, **kwargs: Any) -> None:
5
+ """
6
+ Generic callback function that does nothing and returns nothing
7
+ """
8
+ pass