Skip to content

Commit f343b4d

Browse files
authored
Merge pull request #16 from Accenture/chore/code-quality-review
Code quality round: lint and type gates adopted, wire contract typed end to end
2 parents 7356c66 + 82cf257 commit f343b4d

18 files changed

Lines changed: 430 additions & 233 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ build/
99
.mypy_cache/
1010
.ruff_cache/
1111
uv.lock
12+
.idea/
1213
.DS_Store
1314

1415
# === agent-memory: AI infrastructure (personal / per-machine — do not commit) ===

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ graph task calls a Python function exactly as if it were local.
2626

2727
```python
2828
# app.py
29-
from mercury_composable import AppException, platform, preload
29+
from mercury_composable import AppException, Body, platform, preload
3030

3131
@preload(route="hello.python", instances=10)
32-
def handle_event(headers: dict, body):
32+
def handle_event(headers: dict[str, str], body: Body):
3333
if not isinstance(body, dict) or "text" not in body:
3434
raise AppException(400, "missing 'text'")
3535
return {"text": str(body["text"]).upper(), "language": "python"}
@@ -66,7 +66,7 @@ now executes the Python function, with trace context carried end to end.
6666
## The function contract
6767

6868
A handler receives the same two-part input as an engine `TypedLambdaFunction` —
69-
`(headers: dict, body)` — and returns the reply body (or an `EventEnvelope` for full
69+
`(headers: dict[str, str], body: Body)` — `Body` is any MsgPack value — and returns the reply body (or an `EventEnvelope` for full
7070
control of status and reply headers). `async def` and plain `def` are both supported;
7171
synchronous handlers run in a thread-pool executor so the event loop never blocks.
7272

@@ -75,6 +75,8 @@ synchronous handlers run in a thread-pool executor so the event loop never block
7575
exception handler or the graph's `error.*` contract.
7676
- `get_trace()` exposes `trace_id` / `trace_path` / `cid`; `annotate_trace(k, v)` sends an
7777
annotation back on the reply envelope.
78+
- Outside a hosted function (batch jobs, tests), `trace_context(trace_id, trace_path)`
79+
establishes the context your `PostOffice` calls inherit — the node `runWithTrace` twin.
7880
- Functions must be stateless; anything you must keep belongs to the caller's flow model
7981
or state machine.
8082

@@ -125,6 +127,19 @@ orchestration** — those live in the engines. It provides functions plus the mi
125127
foundation utilities, keeping Python fast to prototype with while the composable core
126128
guarantees the architecture.
127129

130+
## Development
131+
132+
```bash
133+
uv venv .venv && uv pip install -e '.[dev]' # environment (uv-managed python)
134+
.venv/bin/pytest -q # tests
135+
uvx ruff check . # lint (config in pyproject.toml)
136+
uvx basedpyright # type check (config in pyproject.toml)
137+
```
138+
139+
PyCharm: use interpreter type **uv** pointing at the project `.venv`, and set
140+
*Settings → Tools → Python Integrated Tools → Package requirements file* to
141+
`pyproject.toml` so the requirements inspection reads `[project.dependencies]`.
142+
128143
## License
129144

130145
Apache 2.0 — see [LICENSE.txt](LICENSE.txt).

examples/demo_app.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,26 @@
1010
target: 'http://127.0.0.1:8086/api/event'
1111
"""
1212

13-
from mercury_composable import AppException, annotate_trace, get_logger, platform, preload
13+
from mercury_composable import (
14+
AppException,
15+
Body,
16+
annotate_trace,
17+
get_logger,
18+
platform,
19+
preload,
20+
)
1421

1522
log = get_logger(__name__)
1623

1724

1825
@preload(route="hello.python", instances=10)
19-
def handle_event(headers: dict, body):
20-
"""Uppercase transform - the polyglot hello world."""
26+
def handle_event(_headers: dict[str, str], body: Body):
27+
"""Uppercase transform - the polyglot hello world.
28+
29+
The (headers, body) two-part signature is the function contract (the
30+
TypedLambdaFunction mirror) - a handler that does not need headers keeps
31+
the parameter, underscore-prefixed per Python convention.
32+
"""
2133
if not isinstance(body, dict) or "text" not in body:
2234
raise AppException(400, "missing 'text'")
2335
annotate_trace("language", "python")
@@ -26,7 +38,7 @@ def handle_event(headers: dict, body):
2638

2739

2840
@preload(route="hello.declarative", instances=10)
29-
async def declarative_echo(headers: dict, body):
41+
async def declarative_echo(headers: dict[str, str], body: Body):
3042
"""Echo for the composable-example declarative Event-over-HTTP demo."""
3143
return {"body": body, "headers": headers, "language": "python"}
3244

pyproject.toml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,35 @@ Homepage = "https://github.com/Accenture/mercury-python"
2727
Documentation = "https://accenture.github.io/mercury-composable"
2828

2929
[project.optional-dependencies]
30-
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
30+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.9"]
3131

3232
[project.scripts]
3333
mercury-serve = "mercury_composable.cli:main"
3434

3535
[tool.hatch.build.targets.wheel]
3636
packages = ["src/mercury_composable"]
3737

38+
[tool.ruff]
39+
line-length = 100
40+
target-version = "py310"
41+
# the agent-skills layer is tool-managed by agent-memory (overwritten on upgrade);
42+
# style fixes for it belong upstream, not here
43+
extend-exclude = ["agent-skills"]
44+
45+
[tool.ruff.lint]
46+
extend-select = ["I"]
47+
48+
[tool.basedpyright]
49+
# standard mode plus the contract-relevant strictness: every function parameter
50+
# is annotated, unit tests included (the wire contract is typed - Body,
51+
# dict[str, str]); the reportUnknown* warning family of the editor's
52+
# "recommended" mode is deliberately not chased. agent-skills is tool-managed
53+
# by agent-memory and excluded.
54+
typeCheckingMode = "standard"
55+
reportMissingParameterType = "error"
56+
include = ["src", "examples", "tests"]
57+
exclude = ["agent-skills", ".venv"]
58+
3859
[tool.pytest.ini_options]
3960
asyncio_mode = "auto"
4061
testpaths = ["tests"]

src/mercury_composable/__init__.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,36 @@
1111

1212
from .client import PostOffice
1313
from .config import AppConfig, app_config, load_config
14-
from .envelope import EventEnvelope, iso_utc
14+
from .envelope import Body, EventEnvelope, iso_utc
1515
from .exceptions import AppException, CompactFormatError
1616
from .log import get_logger
17-
from .registry import FunctionRegistry, default_registry, preload
17+
from .registry import FunctionRegistry, Handler, default_registry, preload
1818
from .server import EventApiServer, Platform, platform
19-
from .trace import TraceInfo, annotate_trace, get_trace
19+
from .trace import TraceInfo, annotate_trace, get_trace, trace_context
2020

2121
__version__ = "0.1.0"
2222

2323
__all__ = [
24-
"AppConfig", "AppException", "CompactFormatError", "EventApiServer",
25-
"EventEnvelope", "FunctionRegistry", "Platform", "PostOffice", "TraceInfo",
26-
"annotate_trace", "app_config", "default_registry", "get_logger",
27-
"get_trace", "iso_utc", "load_config", "platform", "preload", "__version__",
24+
"AppConfig",
25+
"AppException",
26+
"Body",
27+
"CompactFormatError",
28+
"EventApiServer",
29+
"EventEnvelope",
30+
"FunctionRegistry",
31+
"Handler",
32+
"Platform",
33+
"PostOffice",
34+
"TraceInfo",
35+
"__version__",
36+
"annotate_trace",
37+
"app_config",
38+
"default_registry",
39+
"get_logger",
40+
"get_trace",
41+
"iso_utc",
42+
"load_config",
43+
"platform",
44+
"preload",
45+
"trace_context",
2846
]

src/mercury_composable/cli.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ def main() -> int:
2525
help="Configuration file (default: resources/application.yml|properties)")
2626
# -Dkey=value runtime overrides are consumed by AppConfig from sys.argv
2727
args, _unknown = parser.parse_known_args()
28+
# argparse Namespace attributes are untyped - pin the types at the boundary
29+
app_arg: str = args.app
30+
port_arg: int | None = args.port
31+
host_arg: str = args.host
32+
config_arg: str | None = args.config
2833

2934
from .config import DEFAULT_CANDIDATES, load_config
30-
app_path = os.path.abspath(args.app)
31-
config_path = args.config
35+
app_path = os.path.abspath(app_arg)
36+
config_path: str | None = config_arg
3237
if config_path is None and not any(os.path.isfile(c) for c in DEFAULT_CANDIDATES):
3338
# fall back to a resources folder next to the application file
3439
app_dir = os.path.dirname(app_path)
@@ -56,7 +61,7 @@ def main() -> int:
5661
print("No functions registered - use @preload(route=..., instances=...)",
5762
file=sys.stderr)
5863
return 1
59-
platform.run(port=args.port, host=args.host)
64+
platform.run(port=port_arg, host=host_arg)
6065
return 0
6166

6267

src/mercury_composable/client.py

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from __future__ import annotations
1616

1717
import re
18-
from typing import Any, Dict, Optional
18+
from typing import Any
1919

2020
import aiohttp
2121

@@ -27,32 +27,52 @@
2727
_W3C_SPAN_ID = re.compile(r"^[0-9a-f]{16}$")
2828

2929

30+
def _build_event(route: str, body: Any, headers: dict[str, str] | None,
31+
from_route: str | None, cid: str | None) -> EventEnvelope:
32+
"""Build the outbound envelope, inheriting the current trace context."""
33+
event = EventEnvelope(to=route, body=body, headers=headers or {})
34+
if from_route:
35+
event.set_from(from_route)
36+
info = get_trace()
37+
if info and info.trace_id:
38+
event.set_trace(info.trace_id, info.trace_path or route)
39+
effective_cid = cid or (info.cid if info else None)
40+
if effective_cid:
41+
event.set_correlation_id(effective_cid)
42+
return event
43+
44+
3045
class PostOffice:
3146
"""Event-over-HTTP client for calling functions on peer applications."""
3247

33-
def __init__(self, endpoint: Optional[str] = None,
34-
security_headers: Optional[Dict[str, str]] = None):
48+
def __init__(self, endpoint: str | None = None,
49+
security_headers: dict[str, str] | None = None):
3550
self.endpoint = endpoint
3651
self.security_headers = dict(security_headers or {})
37-
self._session: Optional[aiohttp.ClientSession] = None
52+
self._session: aiohttp.ClientSession | None = None
3853

39-
async def _get_session(self) -> aiohttp.ClientSession:
40-
if self._session is None or self._session.closed:
41-
self._session = aiohttp.ClientSession()
42-
return self._session
54+
def _get_session(self) -> aiohttp.ClientSession:
55+
# called from the running event loop only (inside request/send)
56+
session = self._session
57+
if session is None or session.closed:
58+
session = aiohttp.ClientSession()
59+
self._session = session
60+
return session
4361

4462
async def close(self) -> None:
4563
if self._session is not None and not self._session.closed:
4664
await self._session.close()
4765

48-
async def __aenter__(self) -> "PostOffice":
66+
# PYI034 wants '-> Self', which needs python >= 3.11; switch when the
67+
# floor moves past 3.10
68+
async def __aenter__(self) -> PostOffice: # noqa: PYI034
4969
return self
5070

51-
async def __aexit__(self, *_exc) -> None:
71+
async def __aexit__(self, *_exc: object) -> None:
5272
await self.close()
5373

5474
def _http_headers(self, timeout_ms: int, is_async: bool,
55-
event: EventEnvelope) -> Dict[str, str]:
75+
event: EventEnvelope) -> dict[str, str]:
5676
headers = {
5777
"content-type": "application/octet-stream",
5878
"accept": "*/*",
@@ -71,28 +91,15 @@ def _http_headers(self, timeout_ms: int, is_async: bool,
7191
headers["traceparent"] = f"00-{event.trace_id}-{event.span_id}-01"
7292
return headers
7393

74-
def _build_event(self, route: str, body: Any, headers: Optional[Dict[str, str]],
75-
from_route: Optional[str], cid: Optional[str]) -> EventEnvelope:
76-
event = EventEnvelope(to=route, body=body, headers=headers or {})
77-
if from_route:
78-
event.set_from(from_route)
79-
info = get_trace()
80-
if info and info.trace_id:
81-
event.set_trace(info.trace_id, info.trace_path or route)
82-
effective_cid = cid or (info.cid if info else None)
83-
if effective_cid:
84-
event.set_correlation_id(effective_cid)
85-
return event
86-
87-
async def _call(self, route: str, body: Any, headers: Optional[Dict[str, str]],
88-
timeout_ms: int, endpoint: Optional[str], is_async: bool,
89-
from_route: Optional[str], cid: Optional[str]) -> EventEnvelope:
94+
async def _call(self, route: str, body: Any, headers: dict[str, str] | None,
95+
timeout_ms: int, endpoint: str | None, is_async: bool,
96+
from_route: str | None, cid: str | None) -> EventEnvelope:
9097
url = endpoint or self.endpoint
9198
if not url:
9299
raise ValueError("Missing event endpoint - "
93100
"e.g. PostOffice(endpoint='http://peer:8085/api/event')")
94-
event = self._build_event(route, body, headers, from_route, cid)
95-
session = await self._get_session()
101+
event = _build_event(route, body, headers, from_route, cid)
102+
session = self._get_session()
96103
# +100 ms cushion so the HTTP client does not time out before the target
97104
client_timeout = aiohttp.ClientTimeout(total=(max(100, timeout_ms) + 100) / 1000)
98105
async with session.post(url, data=event.to_bytes(),
@@ -106,21 +113,21 @@ async def _call(self, route: str, body: Any, headers: Optional[Dict[str, str]],
106113
f"Invalid event-over-http response - {e}") from e
107114

108115
async def request(self, route: str, body: Any = None, *,
109-
headers: Optional[Dict[str, str]] = None,
116+
headers: dict[str, str] | None = None,
110117
timeout_ms: int = 30000,
111-
endpoint: Optional[str] = None,
112-
from_route: Optional[str] = None,
113-
cid: Optional[str] = None) -> EventEnvelope:
118+
endpoint: str | None = None,
119+
from_route: str | None = None,
120+
cid: str | None = None) -> EventEnvelope:
114121
"""RPC call: returns the target function's reply envelope."""
115122
return await self._call(route, body, headers, timeout_ms, endpoint,
116123
False, from_route, cid)
117124

118125
async def send(self, route: str, body: Any = None, *,
119-
headers: Optional[Dict[str, str]] = None,
126+
headers: dict[str, str] | None = None,
120127
timeout_ms: int = 30000,
121-
endpoint: Optional[str] = None,
122-
from_route: Optional[str] = None,
123-
cid: Optional[str] = None) -> EventEnvelope:
128+
endpoint: str | None = None,
129+
from_route: str | None = None,
130+
cid: str | None = None) -> EventEnvelope:
124131
"""Drop-n-forget: returns the peer's 202 delivery acknowledgement envelope."""
125132
return await self._call(route, body, headers, timeout_ms, endpoint,
126133
True, from_route, cid)

0 commit comments

Comments
 (0)