fixtureqa 0.9.1__tar.gz → 0.9.2__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 (109) hide show
  1. {fixtureqa-0.9.1/fixtureqa.egg-info → fixtureqa-0.9.2}/PKG-INFO +3 -2
  2. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/app.py +4 -1
  3. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/deps.py +5 -0
  4. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/fix_spec.py +38 -10
  5. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/sessions.py +25 -4
  6. fixtureqa-0.9.2/fixture/api/routers/specs.py +55 -0
  7. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/atomic_io.py +22 -0
  8. fixtureqa-0.9.2/fixture/core/fix_spec_parser.py +160 -0
  9. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/session.py +15 -5
  10. fixtureqa-0.9.2/fixture/core/spec_store.py +147 -0
  11. fixtureqa-0.9.1/fixture/static/assets/index-CbJP_HMn.js → fixtureqa-0.9.2/fixture/static/assets/index-WJ1w221m.js +22 -22
  12. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/index.html +1 -1
  13. {fixtureqa-0.9.1 → fixtureqa-0.9.2/fixtureqa.egg-info}/PKG-INFO +3 -2
  14. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/SOURCES.txt +4 -3
  15. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/requires.txt +2 -1
  16. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/pyproject.toml +5 -3
  17. fixtureqa-0.9.2/tests/test_specs.py +182 -0
  18. fixtureqa-0.9.1/fixture/core/fix_spec_parser.py +0 -172
  19. fixtureqa-0.9.1/fixture/fix_specs/FIX42.xml +0 -2746
  20. fixtureqa-0.9.1/fixture/fix_specs/FIX44.xml +0 -6593
  21. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/LICENSE +0 -0
  22. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/README.md +0 -0
  23. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/__init__.py +0 -0
  24. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/__main__.py +0 -0
  25. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/__init__.py +0 -0
  26. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/connection_manager.py +0 -0
  27. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/__init__.py +0 -0
  28. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/admin.py +0 -0
  29. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/auth.py +0 -0
  30. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/branding.py +0 -0
  31. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/custom_tags.py +0 -0
  32. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/messages.py +0 -0
  33. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/perf.py +0 -0
  34. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/scenarios.py +0 -0
  35. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/setup.py +0 -0
  36. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/spec_overlay.py +0 -0
  37. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/templates.py +0 -0
  38. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/ws.py +0 -0
  39. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/schemas.py +0 -0
  40. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/config/__init__.py +0 -0
  41. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/__init__.py +0 -0
  42. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/auth.py +0 -0
  43. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/config_store.py +0 -0
  44. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/custom_tag_store.py +0 -0
  45. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/db_migrations.py +0 -0
  46. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/events.py +0 -0
  47. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_application.py +0 -0
  48. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_builder.py +0 -0
  49. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_parser.py +0 -0
  50. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_tags.py +0 -0
  51. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_time.py +0 -0
  52. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/housekeeping.py +0 -0
  53. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/inbound.py +0 -0
  54. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/json_store.py +0 -0
  55. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/latency_csv_writer.py +0 -0
  56. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/message_log.py +0 -0
  57. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/message_store.py +0 -0
  58. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/models.py +0 -0
  59. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_charts.py +0 -0
  60. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_engine.py +0 -0
  61. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_models.py +0 -0
  62. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_payload.py +0 -0
  63. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_stats.py +0 -0
  64. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_store.py +0 -0
  65. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_writer.py +0 -0
  66. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/scenario_runner.py +0 -0
  67. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/scenario_store.py +0 -0
  68. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/session_manager.py +0 -0
  69. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/spec_overlay_store.py +0 -0
  70. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/template_store.py +0 -0
  71. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/user_store.py +0 -0
  72. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/value_gen.py +0 -0
  73. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/venue_responses.py +0 -0
  74. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/server.py +0 -0
  75. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  76. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/index-B-hpSZWZ.css +0 -0
  77. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  78. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  79. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/favicon.svg +0 -0
  80. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/ui/__init__.py +0 -0
  81. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/dependency_links.txt +0 -0
  82. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/entry_points.txt +0 -0
  83. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/top_level.txt +0 -0
  84. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/setup.cfg +0 -0
  85. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_atomic_io.py +0 -0
  86. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_auth.py +0 -0
  87. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_config_store.py +0 -0
  88. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_connection_manager.py +0 -0
  89. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_db_migrations.py +0 -0
  90. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_fix_builder.py +0 -0
  91. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_health.py +0 -0
  92. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_inbound.py +0 -0
  93. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_inbound_validation.py +0 -0
  94. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_message_store.py +0 -0
  95. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_non_stop_session.py +0 -0
  96. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_api.py +0 -0
  97. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_engine.py +0 -0
  98. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_models.py +0 -0
  99. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_payload.py +0 -0
  100. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_rehydrate.py +0 -0
  101. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_scenarios.py +0 -0
  102. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_session_auto_response.py +0 -0
  103. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_session_lifecycle.py +0 -0
  104. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_session_manager_concurrency.py +0 -0
  105. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_sessions.py +0 -0
  106. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_templates.py +0 -0
  107. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_timezone_data.py +0 -0
  108. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_value_gen.py +0 -0
  109. {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_ws.py +0 -0
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.9.1
3
+ Version: 0.9.2
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
7
- Requires-Dist: fixcore-engine==0.8.1
7
+ Requires-Dist: fixcore-engine==0.9.1
8
8
  Requires-Dist: fastapi>=0.111.0
9
9
  Requires-Dist: uvicorn[standard]>=0.29.0
10
10
  Requires-Dist: websockets>=12.0
11
11
  Requires-Dist: python-jose[cryptography]>=3.3.0
12
12
  Requires-Dist: passlib[bcrypt]>=1.7.4
13
+ Requires-Dist: python-multipart>=0.0.9
13
14
  Requires-Dist: matplotlib>=3.7
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -12,13 +12,14 @@ from ..core.session_manager import SessionManager
12
12
  from ..core.scenario_store import ScenarioStore, ScenarioGroupStore
13
13
  from ..core.scenario_runner import ScenarioRunner
14
14
  from ..core.spec_overlay_store import SpecOverlayStore
15
+ from ..core.spec_store import SpecStore
15
16
  from ..core.template_store import TemplateStore
16
17
  from ..core.user_store import UserStore
17
18
  from .connection_manager import ConnectionManager
18
19
  from ..core.perf_engine import RunRegistry
19
20
  from ..core.perf_writer import PerfWriter
20
21
  from ..core.perf_store import PerfStore
21
- from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay, perf
22
+ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay, specs, perf
22
23
 
23
24
 
24
25
  @asynccontextmanager
@@ -89,6 +90,7 @@ def create_app(
89
90
  app.state.scenario_group_store = ScenarioGroupStore(data_dir)
90
91
  app.state.custom_tag_store = CustomTagStore(data_dir)
91
92
  app.state.spec_overlay_store = SpecOverlayStore(data_dir)
93
+ app.state.spec_store = SpecStore(data_dir)
92
94
  perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
93
95
  app.state.perf_writer = perf_writer
94
96
  app.state.perf_registry = RunRegistry(
@@ -114,6 +116,7 @@ def create_app(
114
116
  app.include_router(branding.router, prefix="/api")
115
117
  app.include_router(custom_tags.router, prefix="/api/custom-tags")
116
118
  app.include_router(spec_overlay.router, prefix="/api/spec-overlay")
119
+ app.include_router(specs.router, prefix="/api/specs")
117
120
  app.include_router(perf.router, prefix="/api/perf")
118
121
  app.include_router(ws.router)
119
122
 
@@ -12,6 +12,7 @@ from ..core.scenario_store import ScenarioStore, ScenarioGroupStore
12
12
  from ..core.scenario_runner import ScenarioRunner
13
13
  from ..core.custom_tag_store import CustomTagStore
14
14
  from ..core.spec_overlay_store import SpecOverlayStore
15
+ from ..core.spec_store import SpecStore
15
16
  from .connection_manager import ConnectionManager
16
17
 
17
18
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
@@ -104,6 +105,10 @@ def get_spec_overlay_store(request: HTTPConnection) -> SpecOverlayStore:
104
105
  return request.app.state.spec_overlay_store
105
106
 
106
107
 
108
+ def get_spec_store(request: HTTPConnection) -> SpecStore:
109
+ return request.app.state.spec_store
110
+
111
+
107
112
  def get_perf_registry(request: HTTPConnection):
108
113
  return request.app.state.perf_registry
109
114
 
@@ -2,17 +2,22 @@ from typing import Annotated
2
2
 
3
3
  from fastapi import APIRouter, Depends, HTTPException, Query
4
4
 
5
- from ...core.fix_spec_parser import get_fields, get_message, get_messages
5
+ from ...core.fix_spec_parser import (
6
+ get_fields, get_message, get_messages, spec_ref_for_version,
7
+ )
6
8
  from ...core.spec_overlay_store import SpecOverlayStore
9
+ from ...core.spec_store import SpecStore
7
10
  from ...core.user_store import User
8
- from ..deps import get_current_user, get_spec_overlay_store
11
+ from ..deps import get_current_user, get_spec_overlay_store, get_spec_store
9
12
  from ..schemas import FieldRefResponse, MessageDefResponse, MessageSummaryResponse
10
13
 
11
14
  router = APIRouter(tags=["fix-spec"])
12
15
 
13
16
  SUPPORTED = {"FIX.4.2", "FIX.4.4"}
17
+ BUNDLED = {"FIX42.xml", "FIX44.xml"}
14
18
 
15
19
  SO = Annotated[SpecOverlayStore, Depends(get_spec_overlay_store)]
20
+ SS = Annotated[SpecStore, Depends(get_spec_store)]
16
21
  CurrentUser = Annotated[User, Depends(get_current_user)]
17
22
 
18
23
 
@@ -24,16 +29,37 @@ def _validate_begin_string(begin_string: str) -> None:
24
29
  )
25
30
 
26
31
 
32
+ def _resolve_spec_ref(
33
+ begin_string: str, spec: str | None, user: User, spec_store: SpecStore
34
+ ) -> str:
35
+ """The fixcore spec ref to browse: an explicit ``spec`` (a bundled name, or a
36
+ custom upload the caller owns) overrides the version-derived bundled spec.
37
+
38
+ A custom path the caller doesn't own is rejected so the browser can't be
39
+ used to read arbitrary files.
40
+ """
41
+ if spec:
42
+ if spec in BUNDLED:
43
+ return spec
44
+ if spec_store.owns_path(user.uid, spec):
45
+ return spec
46
+ raise HTTPException(status_code=403, detail="Spec not accessible")
47
+ _validate_begin_string(begin_string)
48
+ return spec_ref_for_version(begin_string)
49
+
50
+
27
51
  @router.get("/messages", response_model=list[MessageSummaryResponse])
28
52
  def list_messages(
29
53
  begin_string: str = Query("FIX.4.2"),
54
+ spec: str | None = Query(None),
30
55
  overlay_store: SO = ...,
56
+ spec_store: SS = ...,
31
57
  user: CurrentUser = ...,
32
58
  ):
33
- _validate_begin_string(begin_string)
34
- spec = [MessageSummaryResponse(msg_type=m.msg_type, name=m.name, category=m.category)
35
- for m in get_messages(begin_string)]
36
- spec_types = {m.msg_type for m in spec}
59
+ spec_ref = _resolve_spec_ref(begin_string, spec, user, spec_store)
60
+ spec_msgs = [MessageSummaryResponse(msg_type=m.msg_type, name=m.name, category=m.category)
61
+ for m in get_messages(spec_ref)]
62
+ spec_types = {m.msg_type for m in spec_msgs}
37
63
 
38
64
  overlay = overlay_store.get_overlay(user.uid)
39
65
  extra = [
@@ -45,19 +71,21 @@ def list_messages(
45
71
  for om in overlay.get("messages", [])
46
72
  if om.get("msg_type") and om["msg_type"] not in spec_types
47
73
  ]
48
- return spec + extra
74
+ return spec_msgs + extra
49
75
 
50
76
 
51
77
  @router.get("/messages/{msg_type}", response_model=MessageDefResponse)
52
78
  def get_message_def(
53
79
  msg_type: str,
54
80
  begin_string: str = Query("FIX.4.2"),
81
+ spec: str | None = Query(None),
55
82
  overlay_store: SO = ...,
83
+ spec_store: SS = ...,
56
84
  user: CurrentUser = ...,
57
85
  ):
58
- _validate_begin_string(begin_string)
86
+ spec_ref = _resolve_spec_ref(begin_string, spec, user, spec_store)
59
87
 
60
- msg = get_message(begin_string, msg_type)
88
+ msg = get_message(spec_ref, msg_type)
61
89
  if msg is not None:
62
90
  return MessageDefResponse(
63
91
  msg_type=msg.msg_type,
@@ -71,7 +99,7 @@ def get_message_def(
71
99
 
72
100
  # Check user's spec overlay for custom message types
73
101
  overlay = overlay_store.get_overlay(user.uid)
74
- field_defs = get_fields(begin_string)
102
+ field_defs = get_fields(spec_ref)
75
103
  for om in overlay.get("messages", []):
76
104
  if om.get("msg_type") != msg_type:
77
105
  continue
@@ -5,16 +5,33 @@ from ...core.message_store import MessageStore
5
5
  from ...core.session_manager import SessionManager
6
6
  from ...core.models import SessionConfig, ConnectionType, SessionRole
7
7
  from ...core.user_store import User, UserStore
8
+ from ...core.spec_store import SpecStore
8
9
  from ..schemas import SessionConfigRequest, SessionResponse, UpdateSessionRequest, SendMessageRequest, SeqNumRequest, SeqNumResponse
9
- from ..deps import get_session_manager, get_current_user, get_owned_session, get_user_store
10
+ from ..deps import get_session_manager, get_current_user, get_owned_session, get_user_store, get_spec_store
10
11
 
11
12
  router = APIRouter(tags=["sessions"])
12
13
 
13
14
  SM = Annotated[SessionManager, Depends(get_session_manager)]
14
15
  US = Annotated[UserStore, Depends(get_user_store)]
16
+ SS = Annotated[SpecStore, Depends(get_spec_store)]
15
17
  CurrentUser = Annotated[User, Depends(get_current_user)]
16
18
  OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
17
19
 
20
+ # Bundled spec names fixcore resolves by itself (see core.spec_store).
21
+ _BUNDLED_SPECS = {"FIX42.xml", "FIX44.xml"}
22
+
23
+
24
+ def _validate_data_dictionary(value: Optional[str], user: User, spec_store: SpecStore) -> None:
25
+ """A session may reference a bundled spec or a custom upload it owns — never
26
+ an arbitrary filesystem path (which would let it read any file on start)."""
27
+ if not value or value in _BUNDLED_SPECS:
28
+ return
29
+ if not spec_store.owns_path(user.uid, value):
30
+ raise HTTPException(
31
+ status_code=400,
32
+ detail="data_dictionary_path must be a bundled spec name or one of your uploaded specs",
33
+ )
34
+
18
35
 
19
36
  def _config_from_request(body: SessionConfigRequest, owner_uid: str) -> SessionConfig:
20
37
  """Map the request schema onto SessionConfig in one place — create and
@@ -116,7 +133,8 @@ def list_sessions(sm: SM, us: US, user: CurrentUser):
116
133
 
117
134
 
118
135
  @router.post("/", response_model=SessionResponse, status_code=201)
119
- async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: CurrentUser):
136
+ async def create_session(body: SessionConfigRequest, sm: SM, us: US, ss: SS, user: CurrentUser):
137
+ _validate_data_dictionary(body.data_dictionary_path, user, ss)
120
138
  cfg = _config_from_request(body, owner_uid=user.uid)
121
139
 
122
140
  try:
@@ -144,7 +162,7 @@ async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: Curre
144
162
 
145
163
 
146
164
  @router.post("/import", response_model=list[SessionResponse], status_code=201)
147
- def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, user: CurrentUser):
165
+ def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, ss: SS, user: CurrentUser):
148
166
  """
149
167
  Bulk-create sessions from an exported config file.
150
168
  Each entry is processed independently; invalid entries (bad enums, port
@@ -154,6 +172,7 @@ def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, user: Curr
154
172
  created = []
155
173
  for item in body:
156
174
  try:
175
+ _validate_data_dictionary(item.data_dictionary_path, user, ss)
157
176
  cfg = _config_from_request(item, owner_uid=user.uid)
158
177
  session_id = sm.add_session(cfg)
159
178
  except (HTTPException, ValueError, KeyError):
@@ -168,7 +187,9 @@ def get_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: Curre
168
187
 
169
188
 
170
189
  @router.patch("/{session_id}", response_model=SessionResponse)
171
- def update_session(session_id: str, body: UpdateSessionRequest, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
190
+ def update_session(session_id: str, body: UpdateSessionRequest, _cfg: OwnedSession, sm: SM, us: US, ss: SS, user: CurrentUser):
191
+ if "data_dictionary_path" in body.model_fields_set:
192
+ _validate_data_dictionary(body.data_dictionary_path, user, ss)
172
193
  try:
173
194
  sm.update_session(session_id, body)
174
195
  except RuntimeError as e:
@@ -0,0 +1,55 @@
1
+ """Custom FIX spec (DataDictionary) upload/list/delete.
2
+
3
+ Bundled specs (FIX42.xml / FIX44.xml) ship inside fixcore; this router manages
4
+ the per-user *custom* uploads and lists both together so the session builder can
5
+ offer one combined dropdown.
6
+ """
7
+
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
11
+
12
+ from ...core.spec_store import SpecStore, SpecValidationError
13
+ from ...core.user_store import User
14
+ from ..deps import get_current_user, get_spec_store
15
+
16
+ router = APIRouter(tags=["specs"])
17
+
18
+ SS = Annotated[SpecStore, Depends(get_spec_store)]
19
+ CurrentUser = Annotated[User, Depends(get_current_user)]
20
+
21
+ # Reject oversized uploads before reading them into memory (specs are XML text;
22
+ # the full FIX 4.4 dictionary is ~320 KB, so 2 MB is a generous ceiling).
23
+ _MAX_SPEC_BYTES = 2 * 1024 * 1024
24
+
25
+
26
+ @router.get("")
27
+ def list_specs(store: SS, user: CurrentUser) -> dict:
28
+ """Bundled + this user's uploaded custom specs (for the dictionary picker)."""
29
+ return store.list_specs(user.uid)
30
+
31
+
32
+ @router.post("", status_code=status.HTTP_201_CREATED)
33
+ async def upload_spec(file: UploadFile, store: SS, user: CurrentUser) -> dict:
34
+ raw = await file.read()
35
+ if len(raw) > _MAX_SPEC_BYTES:
36
+ raise HTTPException(status_code=413, detail="Spec file too large")
37
+ try:
38
+ text = raw.decode("utf-8")
39
+ except UnicodeDecodeError:
40
+ raise HTTPException(status_code=400, detail="Spec must be UTF-8 XML")
41
+ try:
42
+ return store.save_spec(user.uid, file.filename or "spec.xml", text)
43
+ except SpecValidationError as e:
44
+ raise HTTPException(status_code=400, detail=str(e))
45
+
46
+
47
+ @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
48
+ def delete_spec(name: str, store: SS, user: CurrentUser) -> Response:
49
+ try:
50
+ deleted = store.delete_spec(user.uid, name)
51
+ except SpecValidationError as e:
52
+ raise HTTPException(status_code=400, detail=str(e))
53
+ if not deleted:
54
+ raise HTTPException(status_code=404, detail="Spec not found")
55
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -36,3 +36,25 @@ def atomic_write_json(path: str, data, *, indent: int = 2) -> None:
36
36
  except OSError:
37
37
  pass
38
38
  raise
39
+
40
+
41
+ def atomic_write_text(path: str, text: str) -> None:
42
+ """Atomically write `text` to `path` (same unique-temp + fsync + rename
43
+ scheme as :func:`atomic_write_json`). Used for uploaded FIX spec XML."""
44
+ directory = os.path.dirname(path) or "."
45
+ os.makedirs(directory, exist_ok=True)
46
+ fd, tmp = tempfile.mkstemp(
47
+ prefix=os.path.basename(path) + ".", suffix=".tmp", dir=directory
48
+ )
49
+ try:
50
+ with os.fdopen(fd, "w") as f:
51
+ f.write(text)
52
+ f.flush()
53
+ os.fsync(f.fileno())
54
+ os.replace(tmp, path)
55
+ except BaseException:
56
+ try:
57
+ os.unlink(tmp)
58
+ except OSError:
59
+ pass
60
+ raise
@@ -0,0 +1,160 @@
1
+ """
2
+ Structured FIX field/message definitions for the GUI spec browser.
3
+
4
+ This is a thin adapter over fixcore's :class:`~fixcore.message.DataDictionary`
5
+ — fixture no longer parses spec XML itself. fixcore 0.9.x bundles the FIX 4.2 /
6
+ 4.4 dictionaries and (crucially) expands ``<component>`` references, so the
7
+ field lists here are complete for FIX 4.4 (NewOrderSingle's Instrument /
8
+ OrderQtyData / Parties fields, etc.) where the old in-house parser silently
9
+ dropped every component-sourced field.
10
+
11
+ A *spec ref* is whatever fixcore's ``load_data_dictionary`` accepts: a bundled
12
+ spec name (``"FIX42.xml"`` / ``"FIX44.xml"``) or an absolute path to a custom
13
+ uploaded spec.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from dataclasses import dataclass, field
20
+
21
+ from fixcore.message import load_data_dictionary
22
+ from fixcore.message.data_dictionary import GroupDef
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class FieldDef:
29
+ number: int
30
+ name: str
31
+ type: str # STRING, CHAR, INT, PRICE, etc.
32
+ values: dict[str, str] # enum_value → description; empty if not enum
33
+
34
+
35
+ @dataclass
36
+ class FieldRef:
37
+ tag: int # 0 if unknown
38
+ name: str
39
+ type: str
40
+ required: bool
41
+ values: dict[str, str]
42
+
43
+
44
+ @dataclass
45
+ class MessageDef:
46
+ msg_type: str
47
+ name: str
48
+ category: str # 'admin' | 'app'
49
+ fields: list[FieldRef] = field(default_factory=list)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Internal cache (per spec ref). fixcore caches the parsed DataDictionary
54
+ # itself; this caches the adapter's per-message FieldRef lists.
55
+ # ---------------------------------------------------------------------------
56
+
57
+ @dataclass
58
+ class _Spec:
59
+ field_defs: dict[int, FieldDef]
60
+ messages: dict[str, MessageDef]
61
+
62
+
63
+ _CACHE: dict[str, _Spec] = {}
64
+
65
+
66
+ def spec_ref_for_version(begin_string: str) -> str:
67
+ """The bundled spec name for a FIX version (fixcore resolves it)."""
68
+ return "FIX42.xml" if "4.2" in begin_string else "FIX44.xml"
69
+
70
+
71
+ def _build_spec(spec_ref: str) -> _Spec:
72
+ dd = load_data_dictionary(spec_ref)
73
+
74
+ field_defs: dict[int, FieldDef] = {
75
+ tag: FieldDef(number=fd.number, name=fd.name, type=fd.type, values=dict(fd.values))
76
+ for tag, fd in dd.fields().items()
77
+ }
78
+
79
+ messages: dict[str, MessageDef] = {}
80
+ for m in dd.messages():
81
+ messages[m.msg_type] = MessageDef(
82
+ msg_type=m.msg_type,
83
+ name=m.name,
84
+ category="admin" if m.msg_cat == "admin" else "app",
85
+ fields=_message_field_refs(m, field_defs),
86
+ )
87
+ return _Spec(field_defs=field_defs, messages=messages)
88
+
89
+
90
+ def _message_field_refs(msg, field_defs: dict[int, FieldDef]) -> list[FieldRef]:
91
+ """Flatten a message's fields into one ordered list for the browser.
92
+
93
+ fixcore keeps a message as required/optional tag *sets* plus repeating-group
94
+ structure; we present required fields first, then optional, then expand each
95
+ repeating group's members (as optional) — mirroring the old flat browser but
96
+ now component-complete.
97
+ """
98
+ refs: list[FieldRef] = []
99
+ seen: set[int] = set()
100
+
101
+ def add(tag: int, required: bool) -> None:
102
+ if tag in seen:
103
+ return
104
+ seen.add(tag)
105
+ fd = field_defs.get(tag)
106
+ refs.append(FieldRef(
107
+ tag=tag,
108
+ name=fd.name if fd else f"Tag{tag}",
109
+ type=fd.type if fd else "STRING",
110
+ required=required,
111
+ values=fd.values if fd else {},
112
+ ))
113
+
114
+ def add_group(gd: GroupDef) -> None:
115
+ for tag in gd.members:
116
+ add(tag, False)
117
+ nested = gd.nested_groups.get(tag)
118
+ if nested is not None:
119
+ add_group(nested)
120
+
121
+ for tag in sorted(msg.required):
122
+ add(tag, True)
123
+ for tag in sorted(msg.optional):
124
+ add(tag, False)
125
+ for counter, gd in msg.groups.items():
126
+ add(counter, counter in msg.required)
127
+ add_group(gd)
128
+ return refs
129
+
130
+
131
+ def _get_spec(spec_ref: str) -> _Spec:
132
+ if spec_ref not in _CACHE:
133
+ try:
134
+ _CACHE[spec_ref] = _build_spec(spec_ref)
135
+ except Exception as e:
136
+ logger.error("Failed to load FIX spec %s: %s", spec_ref, e)
137
+ _CACHE[spec_ref] = _Spec(field_defs={}, messages={})
138
+ return _CACHE[spec_ref]
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Public API
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def get_messages(spec_ref: str) -> list[MessageDef]:
146
+ """All message definitions for a spec, app messages first then by name."""
147
+ spec = _get_spec(spec_ref)
148
+ result = list(spec.messages.values())
149
+ result.sort(key=lambda m: (m.category != "app", m.name))
150
+ return result
151
+
152
+
153
+ def get_message(spec_ref: str, msg_type: str) -> MessageDef | None:
154
+ """The full message definition for one MsgType, or None."""
155
+ return _get_spec(spec_ref).messages.get(msg_type)
156
+
157
+
158
+ def get_fields(spec_ref: str) -> dict[int, FieldDef]:
159
+ """All field definitions keyed by tag number."""
160
+ return _get_spec(spec_ref).field_defs
@@ -421,11 +421,21 @@ class Session:
421
421
  return SessionSettings.from_string("\n".join(lines))
422
422
 
423
423
  def _resolve_data_dictionary(self) -> str:
424
- if self.config.data_dictionary_path:
425
- return os.path.abspath(self.config.data_dictionary_path)
426
- here = os.path.dirname(os.path.abspath(__file__))
427
- fname = "FIX42.xml" if "4.2" in self.config.begin_string else "FIX44.xml"
428
- return os.path.abspath(os.path.join(here, "..", "fix_specs", fname))
424
+ """The DataDictionary value for this session — a bundled spec name that
425
+ fixcore resolves from its packaged dictionaries, or an absolute path to
426
+ a custom uploaded spec.
427
+
428
+ fixcore's ``load_data_dictionary`` (used by both the transport and
429
+ :meth:`data_dictionary`) checks disk first and only then falls back to a
430
+ bundled name, so a real file always wins.
431
+ """
432
+ val = self.config.data_dictionary_path
433
+ if val:
434
+ # A custom spec is stored as an on-disk path; anything else (e.g.
435
+ # "FIX44.xml") is a bundled spec name fixcore resolves by itself.
436
+ return os.path.abspath(val) if os.path.isfile(val) else val
437
+ # Default: fixcore's bundled dictionary for this session's FIX version.
438
+ return "FIX42.xml" if "4.2" in self.config.begin_string else "FIX44.xml"
429
439
 
430
440
  def data_dictionary(self):
431
441
  """The loaded DataDictionary for this session's spec (cached by fixcore),
@@ -0,0 +1,147 @@
1
+ """
2
+ Per-user custom FIX spec (DataDictionary XML) storage.
3
+
4
+ Custom specs are uploaded QuickFIX-format XML files persisted at
5
+ ``data/<uid>/specs/<filename>.xml`` — same per-user directory layout as the
6
+ JSON stores, but the payload is raw XML (validated by parsing it through
7
+ fixcore's DataDictionary before it is accepted).
8
+
9
+ The bundled FIX 4.2 / 4.4 dictionaries are *not* stored here — they ship inside
10
+ fixcore (``fixcore.message.bundled_specs()``). :func:`SpecStore.list_specs`
11
+ returns the bundled specs alongside the user's uploaded ones so the UI can show
12
+ one combined dropdown.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import threading
20
+
21
+ from fixcore.message import bundled_specs, load_data_dictionary
22
+
23
+ from .atomic_io import atomic_write_text
24
+
25
+ # A custom spec filename is restricted to a safe, predictable set of characters
26
+ # (it becomes a path segment under the user's data dir) and must end in .xml.
27
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+\.xml$")
28
+
29
+
30
+ class SpecValidationError(ValueError):
31
+ """Raised when an uploaded spec is not a parseable FIX XML dictionary."""
32
+
33
+
34
+ class SpecStore:
35
+ def __init__(self, data_dir: str):
36
+ self._data_dir = data_dir
37
+ self._lock = threading.RLock()
38
+
39
+ # ------------------------------------------------------------------
40
+ # Paths
41
+ # ------------------------------------------------------------------
42
+ def _dir(self, uid: str) -> str:
43
+ return os.path.join(self._data_dir, uid, "specs")
44
+
45
+ def _path(self, uid: str, name: str) -> str:
46
+ return os.path.join(self._dir(uid), name)
47
+
48
+ @staticmethod
49
+ def _sanitize(name: str) -> str:
50
+ """Reduce an uploaded filename to its basename and force a .xml suffix.
51
+
52
+ Rejects anything that isn't a plain ``<word>.xml`` after stripping the
53
+ directory — no path traversal, no surprises as a path segment.
54
+ """
55
+ base = os.path.basename(name or "").strip()
56
+ if not base.lower().endswith(".xml"):
57
+ base += ".xml"
58
+ if not _SAFE_NAME.match(base):
59
+ raise SpecValidationError(
60
+ "Spec filename must be alphanumeric (plus . _ -) and end in .xml"
61
+ )
62
+ return base
63
+
64
+ # ------------------------------------------------------------------
65
+ # Public API
66
+ # ------------------------------------------------------------------
67
+ def save_spec(self, uid: str, filename: str, xml_text: str) -> dict:
68
+ """Validate and persist an uploaded spec; return its descriptor.
69
+
70
+ Raises :class:`SpecValidationError` if the XML doesn't parse as a FIX
71
+ DataDictionary (so a bad upload never lands on disk).
72
+ """
73
+ name = self._sanitize(filename)
74
+ version = _validate_xml(xml_text)
75
+ with self._lock:
76
+ atomic_write_text(self._path(uid, name), xml_text)
77
+ return {"name": name, "version": version, "kind": "custom",
78
+ "value": self._path(uid, name)}
79
+
80
+ def list_specs(self, uid: str) -> dict:
81
+ """Return the bundled specs and this user's uploaded custom specs.
82
+
83
+ Shape::
84
+
85
+ {"bundled": [{"name", "value", "kind": "bundled"}, ...],
86
+ "custom": [{"name", "value", "version", "kind": "custom"}, ...]}
87
+
88
+ ``value`` is what gets stored in ``SessionConfig.data_dictionary_path``:
89
+ a bare bundled name (e.g. ``"FIX44.xml"``, resolved by fixcore) for
90
+ bundled specs, and an absolute path for custom ones.
91
+ """
92
+ bundled = [{"name": n, "value": n, "kind": "bundled"}
93
+ for n in bundled_specs()]
94
+ custom: list[dict] = []
95
+ d = self._dir(uid)
96
+ if os.path.isdir(d):
97
+ for fn in sorted(os.listdir(d)):
98
+ if not fn.lower().endswith(".xml"):
99
+ continue
100
+ path = os.path.join(d, fn)
101
+ version = ""
102
+ try:
103
+ version = load_data_dictionary(path).version
104
+ except Exception:
105
+ pass
106
+ custom.append({"name": fn, "value": path,
107
+ "version": version, "kind": "custom"})
108
+ return {"bundled": bundled, "custom": custom}
109
+
110
+ def delete_spec(self, uid: str, name: str) -> bool:
111
+ name = self._sanitize(name)
112
+ with self._lock:
113
+ path = self._path(uid, name)
114
+ if not os.path.isfile(path):
115
+ return False
116
+ os.remove(path)
117
+ return True
118
+
119
+ def owns_path(self, uid: str, path: str) -> bool:
120
+ """True if *path* is a custom spec file inside *uid*'s spec directory.
121
+
122
+ Used to authorise a ``data_dictionary_path`` a session references — a
123
+ user may only point a session at a bundled spec or their own upload.
124
+ """
125
+ if not path:
126
+ return False
127
+ try:
128
+ d = os.path.realpath(self._dir(uid))
129
+ p = os.path.realpath(path)
130
+ except OSError:
131
+ return False
132
+ return os.path.isfile(p) and os.path.dirname(p) == d
133
+
134
+
135
+ def _validate_xml(xml_text: str) -> str:
136
+ """Parse *xml_text* as a FIX DataDictionary; return its version string.
137
+
138
+ Raises :class:`SpecValidationError` on any parse failure.
139
+ """
140
+ from fixcore.message import DataDictionary
141
+ try:
142
+ dd = DataDictionary.from_string(xml_text)
143
+ except Exception as e: # noqa: BLE001 — surface a clean 4xx, not a 500
144
+ raise SpecValidationError(f"Not a valid FIX dictionary XML: {e}") from e
145
+ if not dd.fields():
146
+ raise SpecValidationError("Spec defines no fields")
147
+ return dd.version