fixtureqa 0.9.1__tar.gz → 0.9.3__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 (110) hide show
  1. {fixtureqa-0.9.1/fixtureqa.egg-info → fixtureqa-0.9.3}/PKG-INFO +3 -2
  2. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/app.py +4 -1
  3. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/deps.py +5 -0
  4. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/fix_spec.py +38 -10
  5. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/sessions.py +25 -4
  6. fixtureqa-0.9.3/fixture/api/routers/specs.py +55 -0
  7. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/atomic_io.py +22 -0
  8. fixtureqa-0.9.3/fixture/core/fix_builder.py +69 -0
  9. fixtureqa-0.9.3/fixture/core/fix_spec_parser.py +160 -0
  10. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/session.py +15 -5
  11. fixtureqa-0.9.3/fixture/core/spec_store.py +147 -0
  12. fixtureqa-0.9.1/fixture/static/assets/index-CbJP_HMn.js → fixtureqa-0.9.3/fixture/static/assets/index-WJ1w221m.js +22 -22
  13. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/static/index.html +1 -1
  14. {fixtureqa-0.9.1 → fixtureqa-0.9.3/fixtureqa.egg-info}/PKG-INFO +3 -2
  15. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixtureqa.egg-info/SOURCES.txt +4 -3
  16. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixtureqa.egg-info/requires.txt +2 -1
  17. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/pyproject.toml +5 -3
  18. fixtureqa-0.9.3/tests/test_specs.py +182 -0
  19. fixtureqa-0.9.1/fixture/core/fix_builder.py +0 -132
  20. fixtureqa-0.9.1/fixture/core/fix_spec_parser.py +0 -172
  21. fixtureqa-0.9.1/fixture/fix_specs/FIX42.xml +0 -2746
  22. fixtureqa-0.9.1/fixture/fix_specs/FIX44.xml +0 -6593
  23. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/LICENSE +0 -0
  24. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/README.md +0 -0
  25. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/__init__.py +0 -0
  26. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/__main__.py +0 -0
  27. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/__init__.py +0 -0
  28. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/connection_manager.py +0 -0
  29. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/__init__.py +0 -0
  30. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/admin.py +0 -0
  31. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/auth.py +0 -0
  32. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/branding.py +0 -0
  33. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/custom_tags.py +0 -0
  34. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/messages.py +0 -0
  35. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/perf.py +0 -0
  36. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/scenarios.py +0 -0
  37. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/setup.py +0 -0
  38. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/spec_overlay.py +0 -0
  39. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/templates.py +0 -0
  40. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/routers/ws.py +0 -0
  41. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/api/schemas.py +0 -0
  42. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/config/__init__.py +0 -0
  43. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/__init__.py +0 -0
  44. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/auth.py +0 -0
  45. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/config_store.py +0 -0
  46. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/custom_tag_store.py +0 -0
  47. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/db_migrations.py +0 -0
  48. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/events.py +0 -0
  49. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/fix_application.py +0 -0
  50. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/fix_parser.py +0 -0
  51. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/fix_tags.py +0 -0
  52. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/fix_time.py +0 -0
  53. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/housekeeping.py +0 -0
  54. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/inbound.py +0 -0
  55. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/json_store.py +0 -0
  56. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/latency_csv_writer.py +0 -0
  57. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/message_log.py +0 -0
  58. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/message_store.py +0 -0
  59. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/models.py +0 -0
  60. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_charts.py +0 -0
  61. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_engine.py +0 -0
  62. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_models.py +0 -0
  63. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_payload.py +0 -0
  64. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_stats.py +0 -0
  65. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_store.py +0 -0
  66. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/perf_writer.py +0 -0
  67. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/scenario_runner.py +0 -0
  68. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/scenario_store.py +0 -0
  69. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/session_manager.py +0 -0
  70. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/spec_overlay_store.py +0 -0
  71. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/template_store.py +0 -0
  72. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/user_store.py +0 -0
  73. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/value_gen.py +0 -0
  74. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/core/venue_responses.py +0 -0
  75. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/server.py +0 -0
  76. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  77. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/static/assets/index-B-hpSZWZ.css +0 -0
  78. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  79. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  80. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/static/favicon.svg +0 -0
  81. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixture/ui/__init__.py +0 -0
  82. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixtureqa.egg-info/dependency_links.txt +0 -0
  83. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixtureqa.egg-info/entry_points.txt +0 -0
  84. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/fixtureqa.egg-info/top_level.txt +0 -0
  85. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/setup.cfg +0 -0
  86. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_atomic_io.py +0 -0
  87. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_auth.py +0 -0
  88. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_config_store.py +0 -0
  89. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_connection_manager.py +0 -0
  90. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_db_migrations.py +0 -0
  91. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_fix_builder.py +0 -0
  92. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_health.py +0 -0
  93. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_inbound.py +0 -0
  94. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_inbound_validation.py +0 -0
  95. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_message_store.py +0 -0
  96. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_non_stop_session.py +0 -0
  97. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_perf_api.py +0 -0
  98. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_perf_engine.py +0 -0
  99. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_perf_models.py +0 -0
  100. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_perf_payload.py +0 -0
  101. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_perf_rehydrate.py +0 -0
  102. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_scenarios.py +0 -0
  103. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_session_auto_response.py +0 -0
  104. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_session_lifecycle.py +0 -0
  105. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_session_manager_concurrency.py +0 -0
  106. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_sessions.py +0 -0
  107. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_templates.py +0 -0
  108. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_timezone_data.py +0 -0
  109. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/tests/test_value_gen.py +0 -0
  110. {fixtureqa-0.9.1 → fixtureqa-0.9.3}/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.3
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.2
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,69 @@
1
+ """
2
+ Group-aware construction of outbound FIX messages from a raw string.
3
+
4
+ The naive path — parse_raw() into a {tag: value} dict, then set_field() per tag —
5
+ collapses repeating groups: duplicate member tags (e.g. three NoAllocs rows) all
6
+ overwrite down to the last value, while the NoXxx count survives as a literal. So
7
+ the wire ends up claiming N instances but carrying only one.
8
+
9
+ This builder instead keeps fields in order and hands them to fixcore's
10
+ ``Message.from_fields`` (the lenient, fields-based counterpart to ``decode``),
11
+ which splits repeating groups using each group's spec definition when a
12
+ DataDictionary is supplied — the same code path the engine uses to decode
13
+ inbound messages, so the two can't drift. Header / seqnum / checksum tags are
14
+ dropped here; the session stamps them on send.
15
+
16
+ Without a DataDictionary (or for a count tag the spec doesn't define) it falls
17
+ back to flat fields — no worse than before, but standard groups now round-trip.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Optional
22
+
23
+ from fixcore.message import DataDictionary, Message
24
+
25
+ SOH = "\x01"
26
+
27
+ # Tags the transport owns; never copied from the user's raw into the body.
28
+ CONTROL_TAGS = frozenset({8, 9, 10, 34, 49, 52, 56})
29
+ _MSG_TYPE = 35
30
+ _TRANSACT_TIME = 60
31
+
32
+
33
+ def _ordered_pairs(raw: str) -> list[tuple[int, str]]:
34
+ """Parse raw FIX into an ORDERED list of (tag, value), preserving duplicates."""
35
+ norm = raw if SOH in raw else raw.replace("|", SOH)
36
+ pairs: list[tuple[int, str]] = []
37
+ for part in norm.split(SOH):
38
+ if "=" not in part:
39
+ continue
40
+ tag_str, _, value = part.partition("=")
41
+ try:
42
+ pairs.append((int(tag_str), value))
43
+ except ValueError:
44
+ continue
45
+ return pairs
46
+
47
+
48
+ def build_message_from_raw(
49
+ raw: str,
50
+ data_dictionary: Optional[DataDictionary] = None,
51
+ *,
52
+ transact_time: Optional[str] = None,
53
+ ) -> Message:
54
+ """Build a fixcore Message from raw FIX, preserving repeating-group structure
55
+ when *data_dictionary* is supplied. Injects TransactTime(60) when
56
+ *transact_time* is given and absent. Raises ValueError if MsgType is missing."""
57
+ pairs = _ordered_pairs(raw)
58
+ msg_idx = next((i for i, (t, _) in enumerate(pairs) if t == _MSG_TYPE), None)
59
+ if msg_idx is None:
60
+ raise ValueError("Missing MsgType (tag 35)")
61
+
62
+ # Drop transport-owned tags; MsgType (35) stays — from_fields routes it to
63
+ # the header. The session stamps the control tags on send.
64
+ fields = [(t, v) for t, v in pairs if t not in CONTROL_TAGS]
65
+ if transact_time is not None and not any(t == _TRANSACT_TIME for t, _ in fields):
66
+ idx = next(i for i, (t, _) in enumerate(fields) if t == _MSG_TYPE)
67
+ fields.insert(idx + 1, (_TRANSACT_TIME, transact_time))
68
+
69
+ return Message.from_fields(fields, data_dictionary)
@@ -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),