turbo-lambda 0.7.1__tar.gz → 0.9.0__tar.gz

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.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: turbo-lambda
3
- Version: 0.7.1
3
+ Version: 0.9.0
4
4
  Summary: Turbo Lambda Description
5
5
  Author: Sam Mosleh
6
6
  Author-email: Sam Mosleh <sam.mosleh.d@gmail.com>
7
7
  Requires-Dist: opentelemetry-api>=1.27.0
8
8
  Requires-Dist: pydantic-settings>=2.11.0
9
- Requires-Python: >=3.12
9
+ Requires-Dist: psycopg[binary]>=3.3.4
10
+ Requires-Python: >=3.14
10
11
  Description-Content-Type: text/markdown
11
12
 
12
13
  # turbo-lambda
@@ -1,13 +1,14 @@
1
1
  [project]
2
2
  name = "turbo-lambda"
3
- version = "0.7.1"
3
+ version = "0.9.0"
4
4
  description = "Turbo Lambda Description"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Sam Mosleh", email = "sam.mosleh.d@gmail.com" }]
7
- requires-python = ">=3.12"
7
+ requires-python = ">=3.14"
8
8
  dependencies = [
9
9
  "opentelemetry-api>=1.27.0",
10
10
  "pydantic-settings>=2.11.0",
11
+ "psycopg[binary]>=3.3.4",
11
12
  ]
12
13
 
13
14
  [dependency-groups]
@@ -22,7 +23,7 @@ dev = [
22
23
  update-turbo-lambda-layer = "turbo_lambda.scripts.update_turbo_lambda_layer:main"
23
24
 
24
25
  [build-system]
25
- requires = ["uv_build>=0.9.2,<0.10.0"]
26
+ requires = ["uv_build>=0.10.2,<0.11.0"]
26
27
  build-backend = "uv_build"
27
28
 
28
29
  [tool.mypy]
@@ -39,18 +40,20 @@ filterwarnings = ["error"]
39
40
  [tool.coverage.run]
40
41
  branch = true
41
42
  parallel = true
42
- relative_files = true
43
43
  source = ["src", "tests"]
44
44
 
45
45
  [tool.coverage.report]
46
46
  show_missing = true
47
- # skip_covered = true
48
47
  partial_branches = [
49
48
  "# pragma: no cover\\b",
50
49
  "# pragma: (nt|posix|cygwin|darwin|linux|msys|win32|cpython|pypy) (no )?cover\\b",
51
50
  "# pragma: (>=?|<=?|==|!=)\\d+\\.\\d+ cover\\b",
52
51
  ]
53
- exclude_also = ['case _:\n\s*assert_never\(.*\)', 'if __name__ == .__main__.:']
52
+ exclude_also = [
53
+ 'case _:\n\s*assert_never\(.*\)',
54
+ 'if __name__ == .__main__.:',
55
+ 'raise$',
56
+ ]
54
57
 
55
58
  [tool.ruff.lint]
56
59
  select = [
@@ -59,11 +62,16 @@ select = [
59
62
  "F", # pyflakes
60
63
  "PL", # pylint
61
64
  "I", # isort
62
- "C", # flake8-comprehensions
65
+ "C4", # flake8-comprehensions
63
66
  "B", # flake8-bugbear
64
67
  "Q", # flake8-quotes
65
68
  "T20", # flake8-print
66
69
  "S", # flake8-bandit
70
+ "PIE", # flake8-pie
71
+ "RET", # flake8-return
72
+ "SIM", # flake8-simplify
73
+ "TC", # flake8-type-checking
74
+ "C90", # mccabe
67
75
  "N", # pep8-naming
68
76
  "UP", # pyupgrade
69
77
  "RUF", # ruff
@@ -72,6 +80,9 @@ ignore = [
72
80
  "E501", # line too long, handled by formatter
73
81
  ]
74
82
 
83
+ [tool.ruff.lint.flake8-type-checking]
84
+ runtime-evaluated-base-classes = ["pydantic.BaseModel"]
85
+
75
86
  [tool.ruff.lint.per-file-ignores]
76
87
  "tests/*" = ["S101", "S311"]
77
88
 
@@ -0,0 +1 @@
1
+ MAX_DYNAMODB_BATCH_WRITE_ITEM = 25
@@ -1,12 +1,10 @@
1
1
  import inspect
2
2
  import logging
3
- from collections.abc import Callable
4
3
  from concurrent.futures import ThreadPoolExecutor
5
4
  from contextlib import AbstractContextManager, ContextDecorator
6
5
  from contextlib import suppress as contextlib_suppress
7
6
  from functools import wraps
8
- from types import TracebackType
9
- from typing import Any, Protocol, overload
7
+ from typing import TYPE_CHECKING, Any, Protocol, overload
10
8
 
11
9
  import pydantic
12
10
  from opentelemetry.trace import format_span_id, format_trace_id, get_current_span
@@ -18,6 +16,10 @@ from turbo_lambda.errors import (
18
16
  )
19
17
  from turbo_lambda.log import log_after_call, logger, logger_bind
20
18
 
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+ from types import TracebackType
22
+
21
23
 
22
24
  class LambdaHandlerT[ResponseT](Protocol):
23
25
  def __call__(
@@ -1,10 +1,11 @@
1
1
  from http import HTTPStatus
2
- from typing import Any
3
-
4
- import pydantic
2
+ from typing import TYPE_CHECKING, Any
5
3
 
6
4
  from turbo_lambda import schemas
7
5
 
6
+ if TYPE_CHECKING:
7
+ import pydantic
8
+
8
9
 
9
10
  class ApplicationError(Exception):
10
11
  pass
@@ -1,3 +1,5 @@
1
+ import annotationlib
2
+ import base64
1
3
  import contextlib
2
4
  import contextvars
3
5
  import datetime
@@ -5,15 +7,16 @@ import inspect
5
7
  import logging
6
8
  import time
7
9
  import uuid
8
- from collections.abc import Callable, Generator, Iterable
9
10
  from enum import Enum
10
11
  from functools import wraps
11
- from typing import Any, overload
12
+ from typing import TYPE_CHECKING, Any, overload
12
13
 
13
14
  import pydantic
14
15
 
15
16
  from turbo_lambda.schemas import IS_LAMBDA
16
17
 
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable, Generator, Iterable
17
20
  LOGGING_CTX: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
18
21
  "LOGGING_CTX"
19
22
  )
@@ -44,6 +47,8 @@ def _json_custom_default(value: Any) -> Any:
44
47
  return str(value)
45
48
  case set():
46
49
  return list(value)
50
+ case bytes():
51
+ return base64.b64encode(value).decode()
47
52
  case _:
48
53
  raise TypeError(value.__class__.__name__)
49
54
 
@@ -57,6 +62,10 @@ def _setup_logger() -> None: # pragma: no cover
57
62
  lambda_runtime_log_utils._json_encoder.default = _json_custom_default
58
63
 
59
64
 
65
+ def _default_result_extractor[T](res: T) -> dict[str, T]:
66
+ return {"result": res}
67
+
68
+
60
69
  @overload
61
70
  def log_after_call[**P, T](func: Callable[P, T]) -> Callable[P, T]: ...
62
71
 
@@ -68,7 +77,7 @@ def log_after_call[**P, T](
68
77
  log_message: str = "call",
69
78
  log_exceptions: bool = False,
70
79
  excluded_fields: Iterable[str] = ("self",),
71
- result_extractor: None = None,
80
+ result_extractor: bool = False,
72
81
  ) -> Callable[[Callable[P, T]], Callable[P, T]]: ...
73
82
 
74
83
 
@@ -89,10 +98,17 @@ def log_after_call[**P, T]( # noqa: PLR0913
89
98
  log_message: str = "call",
90
99
  log_exceptions: bool = False,
91
100
  excluded_fields: Iterable[str] = ("self",),
92
- result_extractor: Callable[[T], dict[str, Any]] | None = None,
101
+ result_extractor: Callable[[T], dict[str, Any]] | bool = False,
93
102
  ) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]:
94
103
  def decorator(func: Callable[P, T]) -> Callable[P, T]:
95
- sig = inspect.signature(func)
104
+ sig = inspect.signature(func, annotation_format=annotationlib.Format.STRING)
105
+ result_extractor_func = (
106
+ result_extractor
107
+ if callable(result_extractor)
108
+ else _default_result_extractor
109
+ if result_extractor
110
+ else None
111
+ )
96
112
 
97
113
  @wraps(func)
98
114
  def wrapper(*f_args: P.args, **f_kwargs: P.kwargs) -> T:
@@ -110,8 +126,8 @@ def log_after_call[**P, T]( # noqa: PLR0913
110
126
  st = time.monotonic()
111
127
  try:
112
128
  result = func(*f_args, **f_kwargs)
113
- if result_extractor:
114
- extra.update(result_extractor(result))
129
+ if result_extractor_func:
130
+ extra.update(result_extractor_func(result))
115
131
  return result
116
132
  except Exception as e:
117
133
  if log_exceptions:
@@ -0,0 +1,107 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
3
+ import psycopg
4
+ from psycopg import pq, sql
5
+ from psycopg.raw_cursor import RawCursorMixin
6
+ from psycopg.rows import Row
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Iterable
10
+ from typing import Self
11
+
12
+ from psycopg.abc import Params, Query
13
+
14
+
15
+ class UnoptimizedQueryError(Exception):
16
+ """Raised when a statement is planned using a sequential scan.
17
+
18
+ The detection is invisible to the interactor: every statement is planned
19
+ with ``EXPLAIN (FORMAT JSON)`` under the hood, so callers cannot tell that
20
+ the plan was inspected.
21
+ """
22
+
23
+ def __init__(self, query: str, plan: dict[str, Any]) -> None:
24
+ self.query = query
25
+ self.plan = plan
26
+ super().__init__(f"Unoptimized query uses a sequential scan: {query!r}")
27
+
28
+
29
+ _EXPLAINABLE_COMMAND_TAGS = frozenset({"SELECT", "INSERT", "UPDATE", "DELETE"})
30
+
31
+
32
+ def _plan_uses_seq_scan(node: dict[str, Any]) -> bool:
33
+ if "Seq Scan" in str(node.get("Node Type", "")):
34
+ return True
35
+ return any(_plan_uses_seq_scan(child) for child in node.get("Plans", []))
36
+
37
+
38
+ def _reject_seq_scan(query: str, payload: list[dict[str, Any]]) -> None:
39
+ root_plan = payload[0]["Plan"]
40
+ if _plan_uses_seq_scan(root_plan):
41
+ raise UnoptimizedQueryError(query, root_plan)
42
+
43
+
44
+ class SeqScanDetectingCursor(psycopg.Cursor[Row]):
45
+ """Raw cursor that rejects any statement planned with a sequential scan.
46
+
47
+ After running each explainable statement it transparently re-plans it with
48
+ ``EXPLAIN (FORMAT JSON)`` under ``SET LOCAL enable_seqscan = off``. A
49
+ sequential scan in that plan means no index can serve the query, even when
50
+ the session allows the planner to prefer sequential scans for optimization.
51
+ """
52
+
53
+ def execute(
54
+ self,
55
+ query: Query,
56
+ params: Params | None = None,
57
+ *,
58
+ prepare: bool | None = None,
59
+ binary: bool | None = None,
60
+ ) -> Self:
61
+ result = super().execute(
62
+ query, # type: ignore
63
+ params,
64
+ prepare=prepare,
65
+ binary=binary,
66
+ )
67
+ if self._status_command() in _EXPLAINABLE_COMMAND_TAGS:
68
+ self._explain_no_seq_scan(query, params)
69
+ return result
70
+
71
+ def executemany(
72
+ self,
73
+ query: Query,
74
+ params_seq: Iterable[Params],
75
+ *,
76
+ returning: bool = False,
77
+ ) -> None:
78
+ params_list = list(params_seq)
79
+ super().executemany(query, params_list, returning=returning)
80
+ if self._status_command() in _EXPLAINABLE_COMMAND_TAGS:
81
+ for params in params_list:
82
+ self._explain_no_seq_scan(query, params)
83
+
84
+ def _status_command(self) -> str | None:
85
+ if self.connection.info.pipeline_status != pq.PipelineStatus.OFF:
86
+ self.connection._pipeline.sync() # type: ignore[union-attr]
87
+ return self.statusmessage.split(maxsplit=1)[0] if self.statusmessage else None
88
+
89
+ def _explain_no_seq_scan(self, query: Query, params: Params | None) -> None:
90
+ explain_query = sql.SQL("EXPLAIN (FORMAT JSON) {}").format(
91
+ sql.SQL(query) if isinstance(query, str) else query
92
+ )
93
+ with psycopg.RawCursor(self.connection) as plan_cursor:
94
+ plan_cursor.execute(explain_query, params)
95
+ row = next(plan_cursor)
96
+ query_label = (
97
+ query
98
+ if isinstance(query, str)
99
+ else explain_query.as_string(self.connection)
100
+ )
101
+ _reject_seq_scan(query_label, row["QUERY PLAN"])
102
+
103
+
104
+ class SeqScanDetectingRawCursor(
105
+ RawCursorMixin[psycopg.Connection[Any], Row], SeqScanDetectingCursor[Row]
106
+ ):
107
+ __module__ = "psycopg"
@@ -1,9 +1,12 @@
1
1
  import argparse
2
2
  import re
3
- from collections.abc import Sequence
3
+ from typing import TYPE_CHECKING
4
4
 
5
5
  from turbo_lambda.version import __version__
6
6
 
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+
7
10
 
8
11
  def main(version: str = __version__, argv: Sequence[str] | None = None) -> int:
9
12
  parser = argparse.ArgumentParser(
File without changes