Skip to content

Commit d8494cd

Browse files
committed
feat: comprehensive audit fixes and new modules (v0.2.0-M7)
P0 bug fixes: - Add asyncio.Lock to InMemoryMessageBroker for concurrent-safe pub/sub - Add asyncio.Lock to InMemoryPersistenceAdapter for concurrent-safe saga state P1 improvements: - Track background tasks during startup, cancel on stop() - Add EventFailureStrategy (LOG/RAISE) to CommandBus event publishing - Implement @controller_advice stereotype for global exception handling - Wire @controller_advice handlers into ControllerRegistrar dispatch chain P2 feature gaps: - Add WebSocket support: @websocket_mapping, WebSocketSession, auto-discovery - Add OAuth2 auto-configuration: resource server, auth server, client registration - Add OAuth2ResourceServerFilter for JWKS-based Bearer token validation - Add session management: HttpSession, SessionStore protocol, memory/Redis adapters - Wire existing Value descriptor into Container field injection - Implement @shell_method_availability for conditional command registration P3 new modules: - Add i18n module: MessageSource protocol, ResourceBundleMessageSource (YAML/JSON), AcceptHeaderLocaleResolver, FixedLocaleResolver, auto-configuration - Add XML serialization: dict_to_xml/xml_to_dict converters, XMLResponse, Accept header content negotiation in controller dispatch - Fix ASGI pathsend to use chunked 64KB reads instead of read_bytes()
1 parent 8d1079f commit d8494cd

42 files changed

Lines changed: 1793 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,18 @@ server = "pyfly.server.auto_configuration:ServerAutoConfiguration"
143143
event-loop = "pyfly.server.auto_configuration:EventLoopAutoConfiguration"
144144
security-jwt = "pyfly.security.auto_configuration:JwtAutoConfiguration"
145145
security-password = "pyfly.security.auto_configuration:PasswordEncoderAutoConfiguration"
146+
oauth2-resource-server = "pyfly.security.auto_configuration:OAuth2ResourceServerAutoConfiguration"
147+
oauth2-authorization-server = "pyfly.security.auto_configuration:OAuth2AuthorizationServerAutoConfiguration"
148+
oauth2-client = "pyfly.security.auto_configuration:OAuth2ClientAutoConfiguration"
146149
scheduling = "pyfly.scheduling.auto_configuration:SchedulingAutoConfiguration"
147150
metrics = "pyfly.observability.auto_configuration:MetricsAutoConfiguration"
148151
tracing = "pyfly.observability.auto_configuration:TracingAutoConfiguration"
149152
actuator = "pyfly.actuator.auto_configuration:ActuatorAutoConfiguration"
150153
actuator-metrics = "pyfly.actuator.auto_configuration:MetricsActuatorAutoConfiguration"
151154
aop = "pyfly.aop.auto_configuration:AopAutoConfiguration"
155+
i18n = "pyfly.i18n.auto_configuration:I18nAutoConfiguration"
156+
session = "pyfly.session.auto_configuration:SessionStoreAutoConfiguration"
157+
session-filter = "pyfly.session.auto_configuration:SessionFilterAutoConfiguration"
152158

153159
[project.scripts]
154160
pyfly = "pyfly.cli.main:cli"

src/pyfly/container/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
component,
2828
configuration,
2929
controller,
30+
controller_advice,
3031
repository,
3132
rest_controller,
3233
service,
@@ -48,6 +49,7 @@
4849
"component",
4950
"configuration",
5051
"controller",
52+
"controller_advice",
5153
"order",
5254
"primary",
5355
"repository",

src/pyfly/container/container.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,14 +248,31 @@ def _resolve_param(self, param_type: type) -> Any:
248248
return self.resolve(param_type)
249249

250250
def _inject_autowired_fields(self, instance: Any) -> None:
251-
"""Inject dependencies into fields marked with Autowired()."""
251+
"""Inject dependencies into fields marked with Autowired() or Value()."""
252+
from pyfly.core.value import Value
253+
252254
try:
253255
hints = typing.get_type_hints(type(instance), include_extras=True)
254256
except Exception:
255257
return
256258

257259
for attr_name, attr_type in hints.items():
258260
default = getattr(type(instance), attr_name, None)
261+
262+
# Handle @Value("${key}") field descriptors
263+
if isinstance(default, Value):
264+
from pyfly.core.config import Config
265+
266+
config_reg = self._registrations.get(Config)
267+
if config_reg is None or config_reg.instance is None:
268+
raise RuntimeError(
269+
f"Cannot resolve @Value for {type(instance).__qualname__}.{attr_name}: "
270+
f"Config bean not registered"
271+
)
272+
resolved = default.resolve(config_reg.instance)
273+
setattr(instance, attr_name, resolved)
274+
continue
275+
259276
if not isinstance(default, Autowired):
260277
continue
261278

src/pyfly/container/stereotypes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,4 @@ def decorator(cls: T) -> T:
8383
rest_controller = _make_stereotype("rest_controller")
8484
configuration = _make_stereotype("configuration")
8585
shell_component = _make_stereotype("shell_component")
86+
controller_advice = _make_stereotype("controller_advice")

src/pyfly/context/application_context.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def __init__(self, config: Config) -> None:
6969
self._started = False
7070
self._infrastructure_adapters: list[Any] = []
7171
self._task_scheduler: Any | None = None
72+
self._background_tasks: list[asyncio.Task[Any]] = []
7273
self._wiring_counts: dict[str, int] = {}
7374

7475
# Register config and container as singleton beans (injectable like Spring's ApplicationContext)
@@ -236,6 +237,14 @@ async def stop(self) -> None:
236237
"""
237238
shutdown_timeout = float(self._config.get("pyfly.context.shutdown-timeout", 30))
238239

240+
# Cancel tracked background tasks
241+
for task in self._background_tasks:
242+
if not task.done():
243+
task.cancel()
244+
if self._background_tasks:
245+
await asyncio.gather(*self._background_tasks, return_exceptions=True)
246+
self._background_tasks.clear()
247+
239248
# Stop task scheduler
240249
if self._task_scheduler is not None:
241250
try:
@@ -594,7 +603,8 @@ def _wire_message_listeners(self) -> None:
594603
topic = getattr(method, "__pyfly_listener_topic__", "")
595604
group = getattr(method, "__pyfly_listener_group__", None)
596605
# MessageBrokerPort.subscribe is async; defer via create_task
597-
asyncio.get_event_loop().create_task(broker.subscribe(topic, method, group=group))
606+
task = asyncio.get_event_loop().create_task(broker.subscribe(topic, method, group=group))
607+
self._background_tasks.append(task)
598608
count += 1
599609
self._wiring_counts["message_listeners"] = count
600610
if count:
@@ -652,7 +662,8 @@ def _wire_scheduled(self) -> None:
652662
self._wiring_counts["scheduled"] = count
653663
if count:
654664
self._task_scheduler = scheduler
655-
asyncio.get_event_loop().create_task(scheduler.start())
665+
task = asyncio.get_event_loop().create_task(scheduler.start())
666+
self._background_tasks.append(task)
656667
logger.debug("Discovered %d @scheduled method(s)", count)
657668

658669
def _wire_async_methods(self) -> None:
@@ -716,6 +727,20 @@ def _wire_shell_commands(self) -> None:
716727
if not getattr(method, "__pyfly_shell_method__", False):
717728
continue
718729

730+
# Check @shell_method_availability
731+
availability_checker_name = getattr(method, "__pyfly_shell_availability__", None)
732+
if availability_checker_name:
733+
checker = getattr(reg.instance, availability_checker_name, None)
734+
if checker is not None:
735+
reason = checker()
736+
if reason:
737+
logger.debug(
738+
"Shell command '%s' unavailable: %s",
739+
getattr(method, "__pyfly_shell_key__", attr_name),
740+
reason,
741+
)
742+
continue
743+
719744
from pyfly.shell.param_inference import infer_params
720745

721746
key = getattr(method, "__pyfly_shell_key__", attr_name)

src/pyfly/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
from pyfly.core.application import PyFlyApplication, pyfly_application
1717
from pyfly.core.banner import BannerMode, BannerPrinter
1818
from pyfly.core.config import Config, config_properties
19+
from pyfly.core.value import Value
1920

2021
__all__ = [
2122
"BannerMode",
2223
"BannerPrinter",
2324
"Config",
2425
"PyFlyApplication",
26+
"Value",
2527
"config_properties",
2628
"pyfly_application",
2729
]

src/pyfly/cqrs/command/bus.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from __future__ import annotations
2323

24+
import enum
2425
import logging
2526
from typing import Any, Protocol, TypeVar, runtime_checkable
2627

@@ -39,6 +40,16 @@
3940
_logger = logging.getLogger(__name__)
4041

4142

43+
class EventFailureStrategy(enum.Enum):
44+
"""Strategy for handling domain event publishing failures."""
45+
46+
LOG = "log"
47+
"""Log failures and continue (default). Command succeeds even if events fail."""
48+
49+
RAISE = "raise"
50+
"""Raise a CommandProcessingException if any event fails to publish."""
51+
52+
4253
@runtime_checkable
4354
class CommandBus(Protocol):
4455
"""Port for sending commands through the CQRS pipeline."""
@@ -73,12 +84,14 @@ def __init__(
7384
authorization: AuthorizationService | None = None,
7485
metrics: CqrsMetricsService | None = None,
7586
event_publisher: Any | None = None,
87+
event_failure_strategy: EventFailureStrategy = EventFailureStrategy.LOG,
7688
) -> None:
7789
self._registry = registry
7890
self._validation = validation
7991
self._authorization = authorization
8092
self._metrics = metrics or CqrsMetricsService()
8193
self._event_publisher = event_publisher
94+
self._event_failure_strategy = event_failure_strategy
8295

8396
# ── CommandBus protocol ────────────────────────────────────
8497

@@ -153,12 +166,22 @@ async def _try_publish_events(self, command: Any, result: Any) -> None:
153166
return
154167
events = getattr(result, "domain_events", None) or getattr(command, "domain_events", None)
155168
if events:
156-
failed_events = []
169+
failed_events: list[tuple[Any, Exception]] = []
157170
for event in events:
158171
try:
159172
await publisher.publish(event)
160173
except Exception as exc:
161174
_logger.error("Failed to publish domain event %s: %s", type(event).__name__, exc)
162-
failed_events.append(event)
163-
if failed_events:
175+
failed_events.append((event, exc))
176+
if failed_events and self._event_failure_strategy == EventFailureStrategy.RAISE:
177+
first_event, first_exc = failed_events[0]
178+
raise CommandProcessingException(
179+
message=(
180+
f"{len(failed_events)} domain event(s) failed to publish "
181+
f"for {type(command).__name__}; first failure: {first_exc}"
182+
),
183+
command_type=type(command),
184+
cause=first_exc,
185+
) from first_exc
186+
elif failed_events:
164187
_logger.error("%d domain event(s) failed to publish", len(failed_events))

src/pyfly/i18n/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright 2026 Firefly Software Solutions Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""PyFly I18n — Internationalisation with pluggable message sources.
15+
16+
Import concrete adapter types from the adapter package::
17+
18+
from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
19+
"""
20+
21+
from pyfly.i18n.locale import (
22+
AcceptHeaderLocaleResolver,
23+
FixedLocaleResolver,
24+
LocaleResolver,
25+
)
26+
from pyfly.i18n.ports.outbound import MessageSource
27+
28+
__all__ = [
29+
"AcceptHeaderLocaleResolver",
30+
"FixedLocaleResolver",
31+
"LocaleResolver",
32+
"MessageSource",
33+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2026 Firefly Software Solutions Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""I18n adapters — concrete message-source implementations."""
15+
16+
from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
17+
18+
__all__ = ["ResourceBundleMessageSource"]

0 commit comments

Comments
 (0)