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.
- {fixtureqa-0.9.1/fixtureqa.egg-info → fixtureqa-0.9.2}/PKG-INFO +3 -2
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/app.py +4 -1
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/deps.py +5 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/fix_spec.py +38 -10
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/sessions.py +25 -4
- fixtureqa-0.9.2/fixture/api/routers/specs.py +55 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/atomic_io.py +22 -0
- fixtureqa-0.9.2/fixture/core/fix_spec_parser.py +160 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/session.py +15 -5
- fixtureqa-0.9.2/fixture/core/spec_store.py +147 -0
- fixtureqa-0.9.1/fixture/static/assets/index-CbJP_HMn.js → fixtureqa-0.9.2/fixture/static/assets/index-WJ1w221m.js +22 -22
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/index.html +1 -1
- {fixtureqa-0.9.1 → fixtureqa-0.9.2/fixtureqa.egg-info}/PKG-INFO +3 -2
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/SOURCES.txt +4 -3
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/requires.txt +2 -1
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/pyproject.toml +5 -3
- fixtureqa-0.9.2/tests/test_specs.py +182 -0
- fixtureqa-0.9.1/fixture/core/fix_spec_parser.py +0 -172
- fixtureqa-0.9.1/fixture/fix_specs/FIX42.xml +0 -2746
- fixtureqa-0.9.1/fixture/fix_specs/FIX44.xml +0 -6593
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/LICENSE +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/README.md +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/__init__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/__main__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/auth.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/events.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/models.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/value_gen.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/server.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/index-B-hpSZWZ.css +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/setup.cfg +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_auth.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_config_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_health.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_inbound.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_message_store.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_non_stop_session.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_sessions.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_templates.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_timezone_data.py +0 -0
- {fixtureqa-0.9.1 → fixtureqa-0.9.2}/tests/test_value_gen.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
spec_types = {m.msg_type for m in
|
|
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
|
|
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
|
-
|
|
86
|
+
spec_ref = _resolve_spec_ref(begin_string, spec, user, spec_store)
|
|
59
87
|
|
|
60
|
-
msg = get_message(
|
|
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(
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|