framework-m 0.12.2__tar.gz → 0.13.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.
Files changed (42) hide show
  1. {framework_m-0.12.2 → framework_m-0.13.0}/CHANGELOG.md +6 -0
  2. {framework_m-0.12.2 → framework_m-0.13.0}/PKG-INFO +3 -3
  3. {framework_m-0.12.2 → framework_m-0.13.0}/pyproject.toml +4 -4
  4. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_jobs_events_webhooks.py +0 -36
  5. framework_m-0.13.0/tests/integration/test_nats_deduplication.py +142 -0
  6. {framework_m-0.12.2 → framework_m-0.13.0}/.gitignore +0 -0
  7. {framework_m-0.12.2 → framework_m-0.13.0}/LICENSE +0 -0
  8. {framework_m-0.12.2 → framework_m-0.13.0}/README.md +0 -0
  9. {framework_m-0.12.2 → framework_m-0.13.0}/alembic/README +0 -0
  10. {framework_m-0.12.2 → framework_m-0.13.0}/alembic/env.py +0 -0
  11. {framework_m-0.12.2 → framework_m-0.13.0}/alembic/script.py.mako +0 -0
  12. {framework_m-0.12.2 → framework_m-0.13.0}/alembic.ini +0 -0
  13. {framework_m-0.12.2 → framework_m-0.13.0}/src/framework_m/__init__.py +0 -0
  14. {framework_m-0.12.2 → framework_m-0.13.0}/src/framework_m/cli/__init__.py +0 -0
  15. {framework_m-0.12.2 → framework_m-0.13.0}/src/framework_m/cli/main.py +0 -0
  16. {framework_m-0.12.2 → framework_m-0.13.0}/src/framework_m/core/services/__init__.py +0 -0
  17. {framework_m-0.12.2 → framework_m-0.13.0}/src/framework_m/core/services/correction.py +0 -0
  18. {framework_m-0.12.2 → framework_m-0.13.0}/src/framework_m/py.typed +0 -0
  19. {framework_m-0.12.2 → framework_m-0.13.0}/tests/adapters/auth/test_oauth_adapter.py +0 -0
  20. {framework_m-0.12.2 → framework_m-0.13.0}/tests/adapters/read_model/test_projector.py +0 -0
  21. {framework_m-0.12.2 → framework_m-0.13.0}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
  22. {framework_m-0.12.2 → framework_m-0.13.0}/tests/architecture/test_frontend_bootstrap.py +0 -0
  23. {framework_m-0.12.2 → framework_m-0.13.0}/tests/architecture/test_package_separation.py +0 -0
  24. {framework_m-0.12.2 → framework_m-0.13.0}/tests/conftest.py +0 -0
  25. {framework_m-0.12.2 → framework_m-0.13.0}/tests/core/interfaces/test_read_model.py +0 -0
  26. {framework_m-0.12.2 → framework_m-0.13.0}/tests/core/interfaces/test_report_engine.py +0 -0
  27. {framework_m-0.12.2 → framework_m-0.13.0}/tests/core/test_correction_service.py +0 -0
  28. {framework_m-0.12.2 → framework_m-0.13.0}/tests/helpers/__init__.py +0 -0
  29. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/__init__.py +0 -0
  30. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_api_endpoints.py +0 -0
  31. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_app_ports.py +0 -0
  32. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_child_table_integration.py +0 -0
  33. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_crud_flow.py +0 -0
  34. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_migration_flow.py +0 -0
  35. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_override_migration.py +0 -0
  36. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_postgres_flow.py +0 -0
  37. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_real_http_client.py +0 -0
  38. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_rfc_0008_audit.py +0 -0
  39. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_rfc_0008_correction.py +0 -0
  40. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_rfc_0008_versioning.py +0 -0
  41. {framework_m-0.12.2 → framework_m-0.13.0}/tests/integration/test_workflow_lifecycle.py +0 -0
  42. {framework_m-0.12.2 → framework_m-0.13.0}/tests/test_meta.py +0 -0
@@ -1,3 +1,9 @@
1
+ ## framework-m v0.13.0
2
+
3
+ ### Features
4
+
5
+ - Implement dual-mode migrations for Pyodide/WASM support (42a8650)
6
+
1
7
  ## framework-m v0.12.2
2
8
 
3
9
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framework-m
3
- Version: 0.12.2
3
+ Version: 0.13.0
4
4
  Summary: A modular, metadata-driven business application framework
5
5
  Project-URL: Homepage, https://gitlab.com/castlecraft/framework-m
6
6
  Project-URL: Documentation, https://gitlab.com/castlecraft/framework-m#readme
@@ -18,8 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.12
21
- Requires-Dist: framework-m-core>=0.24.0
22
- Requires-Dist: framework-m-standard>=0.22.0
21
+ Requires-Dist: framework-m-core>=0.25.0
22
+ Requires-Dist: framework-m-standard>=0.23.0
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # Framework M
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "framework-m"
3
- version = "0.12.2"
3
+ version = "0.13.0"
4
4
  description = "A modular, metadata-driven business application framework"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -21,8 +21,8 @@ classifiers = [
21
21
  ]
22
22
 
23
23
  dependencies = [
24
- "framework-m-core>=0.24.0",
25
- "framework-m-standard>=0.22.0",
24
+ "framework-m-core>=0.25.0",
25
+ "framework-m-standard>=0.23.0",
26
26
  ]
27
27
 
28
28
  [tool.uv.sources]
@@ -40,7 +40,7 @@ dev = [
40
40
  "testcontainers>=4.14.2",
41
41
  "httpx>=0.28.1",
42
42
  "aiosqlite>=0.22.1",
43
- "framework-m-studio>=0.17.2",
43
+ "framework-m-studio>=0.17.3",
44
44
  # Release tools
45
45
  "python-semantic-release>=10.5.3",
46
46
  "build>=1.5.0",
@@ -96,42 +96,6 @@ class TestJobExecutionIntegration:
96
96
  assert status.result == {"result": "success"}
97
97
 
98
98
 
99
- # =============================================================================
100
- # Test: Scheduled Jobs Integration
101
- # =============================================================================
102
-
103
-
104
- class TestScheduledJobsIntegration:
105
- """Integration tests for scheduled job execution."""
106
-
107
- def test_cron_expression_builder(self) -> None:
108
- """build_cron_expression should create valid cron strings."""
109
- from framework_m_standard.adapters.jobs.taskiq_adapter import (
110
- build_cron_expression,
111
- )
112
-
113
- # Every hour at minute 0
114
- expr = build_cron_expression(minute=0)
115
- assert expr == "0 * * * *"
116
-
117
- # Every day at 9am
118
- expr = build_cron_expression(minute=0, hour=9)
119
- assert expr == "0 9 * * *"
120
-
121
- # Every Monday at 2pm
122
- expr = build_cron_expression(minute=0, hour=14, day_of_week=1)
123
- assert expr == "0 14 * * 1"
124
-
125
- def test_cron_schedule_registration(self) -> None:
126
- """cron() should return schedule definition."""
127
- from framework_m_standard.adapters.jobs.taskiq_adapter import cron
128
-
129
- schedule = cron("daily_report", hour=6, minute=0)
130
-
131
- assert schedule["task_name"] == "daily_report"
132
- assert schedule["cron"] == "0 6 * * *"
133
-
134
-
135
99
  # =============================================================================
136
100
  # Test: Webhook Integration
137
101
  # =============================================================================
@@ -0,0 +1,142 @@
1
+ """Integration tests for NATS JetStream deduplication.
2
+
3
+ These tests require a NATS server with JetStream to be running.
4
+ They will be skipped if a connection cannot be established.
5
+
6
+ To run these tests, first start a NATS server:
7
+ docker run -d --rm --name nats-test -p 4222:4222 nats:2.10-alpine --js
8
+ """
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import os
13
+ from collections.abc import AsyncIterator
14
+ from datetime import UTC, datetime
15
+ from typing import Any
16
+ from unittest.mock import AsyncMock
17
+
18
+ import pytest
19
+ from framework_m_core.interfaces.ticker import ScheduleDefinition
20
+ from framework_m_core.jobs_registry import get_global_registry
21
+
22
+
23
+ @pytest.fixture(scope="function", autouse=True)
24
+ async def check_nats_is_available():
25
+ """Skips tests in this module if a NATS server is not available."""
26
+ import nats
27
+
28
+ nats_url = os.environ.get("NATS_URL", "nats://localhost:4222")
29
+ try:
30
+ # Use asyncio.wait_for for a robust timeout that doesn't depend on
31
+ # the nats.connect() keyword argument, which can cause issues.
32
+ nc = await asyncio.wait_for(nats.connect(nats_url), timeout=2)
33
+ await nc.close()
34
+ except Exception as e:
35
+ print(f"\nDEBUG: NATS connection failed with: {e}")
36
+ pytest.skip(
37
+ f"NATS server not available at {nats_url}. Skipping NATS integration tests."
38
+ )
39
+
40
+
41
+ @pytest.fixture(scope="module")
42
+ def nats_url() -> str:
43
+ """Returns the NATS URL from the environment or a default."""
44
+ return os.environ.get("NATS_URL", "nats://localhost:4222")
45
+
46
+
47
+ @pytest.fixture
48
+ async def setup_nats_broker(nats_url: str) -> AsyncIterator[AsyncMock]:
49
+ """Sets up a Taskiq broker and ensures the stream exists."""
50
+ import nats
51
+ from framework_m_standard.adapters.jobs.taskiq_adapter import create_taskiq_broker
52
+ from nats.js.errors import NotFoundError
53
+
54
+ # Ensure a clean state by deleting the stream before the test.
55
+ # This prevents issues where the stream exists from a previous run
56
+ # with a different configuration (e.g., without deduplication).
57
+ nc = await nats.connect(nats_url)
58
+ js = nc.jetstream()
59
+ with contextlib.suppress(NotFoundError):
60
+ await js.delete_stream(name="framework_m_jobs")
61
+ await asyncio.sleep(0.1) # Give NATS time to process deletion
62
+
63
+ await nc.close()
64
+
65
+ broker = create_taskiq_broker(nats_url=nats_url, subject="framework_m_jobs.default")
66
+ await broker.startup()
67
+ yield broker
68
+ await broker.shutdown()
69
+
70
+ # Teardown: Ensure the stream is deleted after the test
71
+ nc = await nats.connect(nats_url)
72
+ js = nc.jetstream()
73
+ with contextlib.suppress(NotFoundError):
74
+ await js.delete_stream(name="framework_m_jobs")
75
+ await nc.close()
76
+
77
+
78
+ class TestNatsDeduplication:
79
+ """Integration tests for NATS JetStream deduplication."""
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_concurrent_ticker_deduplication(
83
+ self, setup_nats_broker: AsyncMock
84
+ ) -> None:
85
+ """Verify NATS deduplicates concurrent job enqueues from multiple tickers."""
86
+ from framework_m_standard.adapters.jobs.taskiq_adapter import (
87
+ TaskiqJobQueueAdapter,
88
+ TaskiqWorkerAdapter,
89
+ job,
90
+ )
91
+
92
+ # 1. Define a mock job that counts its invocations
93
+ invocation_count = 0
94
+
95
+ @job(name="dedup_test_job") # type: ignore[misc]
96
+ async def dedup_test_job(**kwargs: Any) -> None:
97
+ nonlocal invocation_count
98
+ invocation_count += 1
99
+
100
+ # Register the job
101
+ registry = get_global_registry()
102
+ registry.register("dedup_test_job", dedup_test_job)
103
+
104
+ worker = None
105
+ try:
106
+ # 2. Create and start a worker to consume the job
107
+ worker = TaskiqWorkerAdapter(broker=setup_nats_broker, concurrency=1)
108
+ await worker.start()
109
+
110
+ # 3. Create multiple job queues simulating multiple tickers/workers
111
+ schedule = ScheduleDefinition(job_name="dedup_test_job", cron="* * * * *")
112
+ # Use a single queue adapter to avoid concurrent broker startup issues.
113
+ job_queue = TaskiqJobQueueAdapter(broker=setup_nats_broker)
114
+
115
+ # 4. Concurrently enqueue the same job with the same deduplication_id
116
+ # This simulates what multiple tickers would do at the same minute.
117
+ fixed_now = datetime(2024, 1, 1, 10, 30, 15, tzinfo=UTC)
118
+ current_minute = fixed_now.replace(second=0, microsecond=0)
119
+ timestamp = int(current_minute.timestamp())
120
+ deduplication_id = f"tick:{schedule.job_name}:{timestamp}"
121
+
122
+ enqueue_tasks = [
123
+ job_queue.enqueue(
124
+ schedule.job_name,
125
+ deduplication_id=deduplication_id,
126
+ **schedule.kwargs,
127
+ )
128
+ for _ in range(3)
129
+ ]
130
+ await asyncio.gather(*enqueue_tasks)
131
+
132
+ # Give the worker time to process the single message that gets through
133
+ await asyncio.sleep(0.5)
134
+
135
+ # 5. Assert that the job was invoked exactly once
136
+ assert invocation_count == 1
137
+
138
+ finally:
139
+ # 6. Cleanup
140
+ if worker:
141
+ await worker.stop()
142
+ registry.reset()
File without changes
File without changes
File without changes
File without changes