Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.07 (2026-06-05)

### Fixed

- **Web request-binding markers are now type-checker transparent.** `PathVar`,
`QueryParam`, `Body`, `Header`, `Cookie`, `File`, and `Valid` (in
`pyfly.web.params`) were plain `Generic[T]` classes, so `mypy --strict` saw a
handler parameter `order_id: PathVar[str]` as a `PathVar[str]` object rather
than `str` — meaning user controllers written exactly as the docs show could
not pass strict type-checking without `# type: ignore`/`cast`. The markers are
now `Annotated[T, <sentinel>]` aliases, so a type checker sees `PathVar[str]`
as `str` and `Valid[Body[Order]]` as `Order`, while the binder recovers the
source from the annotation metadata at runtime via the new
`pyfly.web.params.inspect_binding`. Runtime binding semantics are unchanged
(path/query/body/header/cookie/file resolution, `Valid[...]` validation, and
the `Valid[Model]` → validated-body shorthand all behave identically).
Internally removed the now-redundant `cast(...)`/`# type: ignore` workarounds
in `idp/web.py` and `transactional/rest/controllers.py`. The whole tree
(`mypy src/pyfly`, 607 files) and the full suite stay green.

---

## v26.06.06 (2026-06-05)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.06-brightgreen" alt="Version: 26.06.06"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.07-brightgreen" alt="Version: 26.06.07"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.6"
version = "26.6.7"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.06"
__version__ = "26.06.07"
22 changes: 11 additions & 11 deletions src/pyfly/idp/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,32 +73,32 @@ def __init__(self, idp_adapter: IdpAdapter) -> None:

@post_mapping("/login")
async def login(self, body: Valid[Body[LoginBody]]) -> dict[str, Any]:
req = cast(LoginBody, body)
req = body
result = await self._idp.login(
LoginRequest(username=req.username, password=req.password, mfa_code=req.mfa_code)
)
return cast(dict[str, Any], _to_dict(result))

@post_mapping("/refresh")
async def refresh(self, body: Valid[Body[TokenBody]]) -> dict[str, Any]:
result = await self._idp.refresh(cast(TokenBody, body).token)
result = await self._idp.refresh(body.token)
return cast(dict[str, Any], _to_dict(result))

@post_mapping("/logout")
async def logout(self, body: Valid[Body[TokenBody]]) -> dict[str, bool]:
ok = await self._idp.logout(cast(TokenBody, body).token)
ok = await self._idp.logout(body.token)
return {"success": ok}

@post_mapping("/introspect")
async def introspect(self, body: Valid[Body[TokenBody]]) -> dict[str, Any]:
result = await self._idp.introspect(cast(TokenBody, body).token)
result = await self._idp.introspect(body.token)
return cast(dict[str, Any], _to_dict(result))

# -- Admin: users -------------------------------------------------------

@post_mapping("/admin/users")
async def create_user(self, body: Valid[Body[CreateUserBody]]) -> dict[str, Any]:
req = cast(CreateUserBody, body)
req = body
user = IdpUser(
username=req.username,
email=req.email,
Expand All @@ -111,29 +111,29 @@ async def create_user(self, body: Valid[Body[CreateUserBody]]) -> dict[str, Any]

@get_mapping("/admin/users/{user_id}")
async def get_user(self, user_id: PathVar[str]) -> dict[str, Any] | None:
user = await self._idp.get_user(cast(str, user_id))
user = await self._idp.get_user(user_id)
return _to_dict(user) if user is not None else None

@get_mapping("/admin/users")
async def list_users(self, limit: QueryParam[int] = 100) -> list[dict[str, Any]]: # type: ignore[assignment]
users = await self._idp.list_users(limit=int(cast(int, limit)))
async def list_users(self, limit: QueryParam[int] = 100) -> list[dict[str, Any]]:
users = await self._idp.list_users(limit=int(limit))
return [cast(dict[str, Any], _to_dict(u)) for u in users]

@delete_mapping("/admin/users/{user_id}")
async def delete_user(self, user_id: PathVar[str]) -> dict[str, bool]:
ok = await self._idp.delete_user(cast(str, user_id))
ok = await self._idp.delete_user(user_id)
return {"success": ok}

# -- Admin: roles -------------------------------------------------------

@post_mapping("/admin/users/{user_id}/roles/{role}")
async def assign_role(self, user_id: PathVar[str], role: PathVar[str]) -> dict[str, bool]:
ok = await self._idp.assign_role(cast(str, user_id), cast(str, role))
ok = await self._idp.assign_role(user_id, role)
return {"success": ok}

@delete_mapping("/admin/users/{user_id}/roles/{role}")
async def revoke_role(self, user_id: PathVar[str], role: PathVar[str]) -> dict[str, bool]:
ok = await self._idp.revoke_role(cast(str, user_id), cast(str, role))
ok = await self._idp.revoke_role(user_id, role)
return {"success": ok}

@get_mapping("/admin/roles")
Expand Down
20 changes: 10 additions & 10 deletions src/pyfly/transactional/rest/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from __future__ import annotations

from typing import Any, cast
from typing import Any

from pydantic import BaseModel

Expand Down Expand Up @@ -100,7 +100,7 @@ def __init__(self, persistence: ExecutionPersistenceProvider) -> None:

@get_mapping("/executions")
async def list_executions(self, status: QueryParam[str | None]) -> list[dict[str, Any]]:
status_str = cast("str | None", status)
status_str = status
if status_str:
states = await self._persistence.find_all(status=ExecutionStatus(status_str))
else:
Expand All @@ -111,7 +111,7 @@ async def list_executions(self, status: QueryParam[str | None]) -> list[dict[str

@get_mapping("/executions/{correlation_id}")
async def get_execution(self, correlation_id: PathVar[str]) -> dict[str, Any] | None:
cid = cast(str, correlation_id)
cid = correlation_id
state = await self._persistence.find(cid)
return _state_to_dict(state) if state is not None else None

Expand All @@ -132,8 +132,8 @@ async def list(
execution_name: QueryParam[str | None],
correlation_id: QueryParam[str | None],
) -> list[dict[str, Any]]:
name = cast("str | None", execution_name)
cid = cast("str | None", correlation_id)
name = execution_name
cid = correlation_id
entries = await self._dlq.list(execution_name=name, correlation_id=cid)
return [_dlq_to_dict(e) for e in entries]

Expand All @@ -144,19 +144,19 @@ async def count(self) -> dict[str, int]:

@get_mapping("/{entry_id}")
async def get(self, entry_id: PathVar[str]) -> dict[str, Any] | None:
eid = cast(str, entry_id)
eid = entry_id
entry = await self._dlq.get(eid)
return _dlq_to_dict(entry) if entry is not None else None

@post_mapping("/{entry_id}/retry")
async def retry(self, entry_id: PathVar[str]) -> dict[str, Any]:
eid = cast(str, entry_id)
eid = entry_id
ok = await self._dlq.mark_retried(eid)
return {"retried": ok}

@delete_mapping("/{entry_id}")
async def delete(self, entry_id: PathVar[str]) -> dict[str, Any]:
eid = cast(str, entry_id)
eid = entry_id
ok = await self._dlq.delete(eid)
return {"deleted": ok}

Expand All @@ -173,7 +173,7 @@ def __init__(self, engine: WorkflowEngine) -> None:

@post_mapping("/start")
async def start(self, body: Valid[Body[StartRequest]]) -> dict[str, Any]:
req = cast(StartRequest, body)
req = body
result = await self._engine.start(req.workflow_id, req.input)
return {
"workflow_id": result.workflow_id,
Expand All @@ -188,7 +188,7 @@ async def start(self, body: Valid[Body[StartRequest]]) -> dict[str, Any]:

@post_mapping("/signal")
async def signal(self, body: Valid[Body[SignalRequest]]) -> dict[str, Any]:
req = cast(SignalRequest, body)
req = body
ok = await self._engine.deliver_signal(req.correlation_id, req.signal, req.payload)
return {"delivered": ok}

Expand Down
38 changes: 9 additions & 29 deletions src/pyfly/web/adapters/starlette/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@
import inspect
import typing
from dataclasses import dataclass, field
from typing import Any, get_args, get_origin
from typing import Any

from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route

from pyfly.web.adapters.starlette.resolver import ParameterResolver
from pyfly.web.adapters.starlette.response import handle_return_value
from pyfly.web.params import Body, Cookie, Header, PathVar, QueryParam, Valid
from pyfly.web.params import Body, Cookie, Header, PathVar, QueryParam, inspect_binding

_BINDING_TYPES = {PathVar, QueryParam, Body, Header, Cookie}
_MISSING = object()


Expand Down Expand Up @@ -224,32 +223,13 @@ def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], t
if hint is None:
continue

origin = get_origin(hint)

# Unwrap Valid[T] for OpenAPI metadata extraction
if origin is Valid:
inner_args = get_args(hint)
if not inner_args:
continue
inner_hint = inner_args[0]
inner_origin = get_origin(inner_hint)
if inner_origin in _BINDING_TYPES:
origin = inner_origin
hint = inner_hint
else:
# Valid[T] standalone → Body[T]
origin = Body
hint = Body[inner_hint] # type: ignore[valid-type]

if origin not in _BINDING_TYPES:
binding, inner_type, _validate = inspect_binding(hint)
if binding is None:
continue

args = get_args(hint)
inner_type = args[0] if args else str

default = param.default if param.default is not inspect.Parameter.empty else _MISSING

if origin is PathVar:
if binding is PathVar:
params.append(
{
"name": name,
Expand All @@ -258,7 +238,7 @@ def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], t
"schema": {"type": _py_type_to_openapi(inner_type)},
}
)
elif origin is QueryParam:
elif binding is QueryParam:
p: dict[str, Any] = {
"name": name,
"in": "query",
Expand All @@ -268,7 +248,7 @@ def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], t
if default is not _MISSING:
p["schema"]["default"] = default
params.append(p)
elif origin is Header:
elif binding is Header:
params.append(
{
"name": name.replace("_", "-"),
Expand All @@ -277,7 +257,7 @@ def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], t
"schema": {"type": _py_type_to_openapi(inner_type)},
}
)
elif origin is Cookie:
elif binding is Cookie:
params.append(
{
"name": name,
Expand All @@ -286,7 +266,7 @@ def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], t
"schema": {"type": _py_type_to_openapi(inner_type)},
}
)
elif origin is Body and isinstance(inner_type, type) and issubclass(inner_type, BaseModel):
elif binding is Body and isinstance(inner_type, type) and issubclass(inner_type, BaseModel):
body_model = inner_type

return params, body_model
Expand Down
40 changes: 10 additions & 30 deletions src/pyfly/web/adapters/starlette/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@
from starlette.requests import Request

from pyfly.kernel.exceptions import InvalidRequestException
from pyfly.web.params import Body, Cookie, File, Header, PathVar, QueryParam, UploadedFile, Valid
from pyfly.web.params import Body, Cookie, File, Header, PathVar, QueryParam, UploadedFile, inspect_binding

_BINDING_TYPES = {PathVar, QueryParam, Body, Header, Cookie, File}
_MISSING = object()


Expand All @@ -45,8 +44,8 @@ class ResolvedParam:
"""Metadata for a single resolved parameter."""

name: str
binding_type: type
inner_type: type
binding_type: Any
inner_type: Any
default: Any = _MISSING
validate: bool = False
required: bool = True
Expand Down Expand Up @@ -80,32 +79,13 @@ def _inspect(self, handler: Any) -> list[ResolvedParam]:
params.append(ResolvedParam(name=name, binding_type=Request, inner_type=Request))
continue

origin = get_origin(hint)
validate = False

# Unwrap Valid[T]: peel the Valid layer to find the inner binding type
if origin is Valid:
validate = True
inner_args = get_args(hint)
if not inner_args:
continue
inner_hint = inner_args[0]
inner_origin = get_origin(inner_hint)

if inner_origin in _BINDING_TYPES:
# Valid[Body[T]], Valid[QueryParam[T]], etc.
origin = inner_origin
hint = inner_hint
else:
# Valid[T] standalone → implies Body[T]
origin = Body
hint = Body[inner_hint] # type: ignore[valid-type]

if origin not in _BINDING_TYPES:
# Recover the binding marker from the annotation metadata.
# ``inspect_binding`` peels ``Valid`` and any binding alias, handling
# the flattened ``Valid[Body[T]]`` -> ``Annotated[T, _BODY, _VALID]`` form.
binding, inner_type, validate = inspect_binding(hint)
if binding is None:
continue

args = get_args(hint)
inner_type = args[0] if args else str
default = param.default if param.default is not inspect.Parameter.empty else _MISSING
# A param is required when it has no Python default and its type does not admit None.
# Body is validated/handled separately, so only scalar bindings enforce presence here.
Expand All @@ -114,7 +94,7 @@ def _inspect(self, handler: Any) -> list[ResolvedParam]:
params.append(
ResolvedParam(
name=name,
binding_type=origin,
binding_type=binding,
inner_type=inner_type,
default=default,
validate=validate,
Expand Down Expand Up @@ -194,7 +174,7 @@ async def _resolve_body(self, request: Request, param: ResolvedParam) -> Any:
context={"errors": errors},
) from exc
return param.inner_type.model_validate_json(body_bytes)
return param.inner_type(body_bytes.decode()) # type: ignore[call-arg]
return param.inner_type(body_bytes.decode())

def _resolve_header(self, request: Request, param: ResolvedParam) -> Any:
header_name = param.name.replace("_", "-")
Expand Down
Loading
Loading