fixtureqa 0.1.6__tar.gz → 0.1.7__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.1.6/fixtureqa.egg-info → fixtureqa-0.1.7}/PKG-INFO +1 -1
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/app.py +7 -1
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/deps.py +10 -0
- fixtureqa-0.1.7/fixture/api/routers/custom_tags.py +41 -0
- fixtureqa-0.1.7/fixture/api/routers/fix_spec.py +96 -0
- fixtureqa-0.1.7/fixture/api/routers/spec_overlay.py +39 -0
- fixtureqa-0.1.7/fixture/core/custom_tag_store.py +53 -0
- fixtureqa-0.1.7/fixture/core/spec_overlay_store.py +33 -0
- fixtureqa-0.1.7/fixture/static/assets/index-DaomB9wG.js +102 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/static/index.html +1 -1
- {fixtureqa-0.1.6 → fixtureqa-0.1.7/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixtureqa.egg-info/SOURCES.txt +5 -1
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/pyproject.toml +1 -1
- fixtureqa-0.1.6/fixture/api/routers/fix_spec.py +0 -41
- fixtureqa-0.1.6/fixture/static/assets/index-nUPn4WaU.js +0 -87
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/LICENSE +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/README.md +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/__init__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/__main__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/auth.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/events.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/models.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/session.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/server.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/static/assets/index-Dv0_KeqF.css +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/setup.cfg +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/tests/test_auth.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/tests/test_sessions.py +0 -0
- {fixtureqa-0.1.6 → fixtureqa-0.1.7}/tests/test_templates.py +0 -0
|
@@ -5,14 +5,16 @@ from fastapi import FastAPI
|
|
|
5
5
|
from fastapi.staticfiles import StaticFiles
|
|
6
6
|
from fastapi.responses import FileResponse
|
|
7
7
|
|
|
8
|
+
from ..core.custom_tag_store import CustomTagStore
|
|
8
9
|
from ..core.housekeeping import HousekeepingService
|
|
9
10
|
from ..core.session_manager import SessionManager
|
|
10
11
|
from ..core.scenario_store import ScenarioStore
|
|
11
12
|
from ..core.scenario_runner import ScenarioRunner
|
|
13
|
+
from ..core.spec_overlay_store import SpecOverlayStore
|
|
12
14
|
from ..core.template_store import TemplateStore
|
|
13
15
|
from ..core.user_store import UserStore
|
|
14
16
|
from .connection_manager import ConnectionManager
|
|
15
|
-
from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding
|
|
17
|
+
from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@asynccontextmanager
|
|
@@ -56,6 +58,8 @@ def create_app(
|
|
|
56
58
|
app.state.housekeeping = HousekeepingService(msg_db_path, log_dir, user_store)
|
|
57
59
|
app.state.template_store = TemplateStore(data_dir)
|
|
58
60
|
app.state.scenario_store = ScenarioStore(data_dir)
|
|
61
|
+
app.state.custom_tag_store = CustomTagStore(data_dir)
|
|
62
|
+
app.state.spec_overlay_store = SpecOverlayStore(data_dir)
|
|
59
63
|
conn_manager: ConnectionManager = app.state.connection_manager
|
|
60
64
|
app.state.scenario_runner = ScenarioRunner(
|
|
61
65
|
session_manager, app.state.template_store, conn_manager.broadcast_scenario_event
|
|
@@ -71,6 +75,8 @@ def create_app(
|
|
|
71
75
|
app.include_router(templates.router, prefix="/api/templates")
|
|
72
76
|
app.include_router(scenarios.router, prefix="/api/scenarios")
|
|
73
77
|
app.include_router(branding.router, prefix="/api")
|
|
78
|
+
app.include_router(custom_tags.router, prefix="/api/custom-tags")
|
|
79
|
+
app.include_router(spec_overlay.router, prefix="/api/spec-overlay")
|
|
74
80
|
app.include_router(ws.router)
|
|
75
81
|
|
|
76
82
|
# Serve built React app from fixture/static/
|
|
@@ -10,6 +10,8 @@ from ..core.housekeeping import HousekeepingService
|
|
|
10
10
|
from ..core.template_store import TemplateStore
|
|
11
11
|
from ..core.scenario_store import ScenarioStore
|
|
12
12
|
from ..core.scenario_runner import ScenarioRunner
|
|
13
|
+
from ..core.custom_tag_store import CustomTagStore
|
|
14
|
+
from ..core.spec_overlay_store import SpecOverlayStore
|
|
13
15
|
from .connection_manager import ConnectionManager
|
|
14
16
|
|
|
15
17
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
|
@@ -71,3 +73,11 @@ def get_scenario_store(request: HTTPConnection) -> ScenarioStore:
|
|
|
71
73
|
|
|
72
74
|
def get_scenario_runner(request: HTTPConnection) -> ScenarioRunner:
|
|
73
75
|
return request.app.state.scenario_runner
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_custom_tag_store(request: HTTPConnection) -> CustomTagStore:
|
|
79
|
+
return request.app.state.custom_tag_store
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_spec_overlay_store(request: HTTPConnection) -> SpecOverlayStore:
|
|
83
|
+
return request.app.state.spec_overlay_store
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from ...core.custom_tag_store import CustomTagStore
|
|
7
|
+
from ...core.user_store import User
|
|
8
|
+
from ..deps import get_current_user, get_custom_tag_store
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["custom-tags"])
|
|
11
|
+
|
|
12
|
+
CT = Annotated[CustomTagStore, Depends(get_custom_tag_store)]
|
|
13
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
14
|
+
|
|
15
|
+
FIELD_TYPES = ["STRING", "CHAR", "INT", "PRICE", "QTY", "BOOLEAN", "UTCTIMESTAMP", "NUMINGROUP", "FLOAT", "SEQNUM"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CustomTagRequest(BaseModel):
|
|
19
|
+
number: int
|
|
20
|
+
name: str
|
|
21
|
+
type: str = "STRING"
|
|
22
|
+
values: dict[str, str] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("")
|
|
26
|
+
def list_custom_tags(store: CT, user: CurrentUser) -> list[dict]:
|
|
27
|
+
return store.list_tags(user.uid)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.post("")
|
|
31
|
+
def upsert_custom_tag(body: CustomTagRequest, store: CT, user: CurrentUser) -> dict:
|
|
32
|
+
if body.number < 1:
|
|
33
|
+
raise HTTPException(status_code=422, detail="Tag number must be >= 1")
|
|
34
|
+
return store.upsert_tag(user.uid, body.number, body.name, body.type, body.values)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.delete("/{number}")
|
|
38
|
+
def delete_custom_tag(number: int, store: CT, user: CurrentUser) -> dict:
|
|
39
|
+
if not store.delete_tag(user.uid, number):
|
|
40
|
+
raise HTTPException(status_code=404, detail="Tag not found")
|
|
41
|
+
return {"ok": True}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
4
|
+
|
|
5
|
+
from ...core.fix_spec_parser import get_fields, get_message, get_messages
|
|
6
|
+
from ...core.spec_overlay_store import SpecOverlayStore
|
|
7
|
+
from ...core.user_store import User
|
|
8
|
+
from ..deps import get_current_user, get_spec_overlay_store
|
|
9
|
+
from ..schemas import FieldRefResponse, MessageDefResponse, MessageSummaryResponse
|
|
10
|
+
|
|
11
|
+
router = APIRouter(tags=["fix-spec"])
|
|
12
|
+
|
|
13
|
+
SUPPORTED = {"FIX.4.2", "FIX.4.4"}
|
|
14
|
+
|
|
15
|
+
SO = Annotated[SpecOverlayStore, Depends(get_spec_overlay_store)]
|
|
16
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_begin_string(begin_string: str) -> None:
|
|
20
|
+
if begin_string not in SUPPORTED:
|
|
21
|
+
raise HTTPException(
|
|
22
|
+
status_code=400,
|
|
23
|
+
detail=f"Unsupported begin_string '{begin_string}'. Use: {sorted(SUPPORTED)}",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/messages", response_model=list[MessageSummaryResponse])
|
|
28
|
+
def list_messages(
|
|
29
|
+
begin_string: str = Query("FIX.4.2"),
|
|
30
|
+
overlay_store: SO = ...,
|
|
31
|
+
user: CurrentUser = ...,
|
|
32
|
+
):
|
|
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}
|
|
37
|
+
|
|
38
|
+
overlay = overlay_store.get_overlay(user.uid)
|
|
39
|
+
extra = [
|
|
40
|
+
MessageSummaryResponse(
|
|
41
|
+
msg_type=om["msg_type"],
|
|
42
|
+
name=om["name"],
|
|
43
|
+
category=om.get("category", "app"),
|
|
44
|
+
)
|
|
45
|
+
for om in overlay.get("messages", [])
|
|
46
|
+
if om.get("msg_type") and om["msg_type"] not in spec_types
|
|
47
|
+
]
|
|
48
|
+
return spec + extra
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/messages/{msg_type}", response_model=MessageDefResponse)
|
|
52
|
+
def get_message_def(
|
|
53
|
+
msg_type: str,
|
|
54
|
+
begin_string: str = Query("FIX.4.2"),
|
|
55
|
+
overlay_store: SO = ...,
|
|
56
|
+
user: CurrentUser = ...,
|
|
57
|
+
):
|
|
58
|
+
_validate_begin_string(begin_string)
|
|
59
|
+
|
|
60
|
+
msg = get_message(begin_string, msg_type)
|
|
61
|
+
if msg is not None:
|
|
62
|
+
return MessageDefResponse(
|
|
63
|
+
msg_type=msg.msg_type,
|
|
64
|
+
name=msg.name,
|
|
65
|
+
category=msg.category,
|
|
66
|
+
fields=[
|
|
67
|
+
FieldRefResponse(tag=f.tag, name=f.name, type=f.type, required=f.required, values=f.values)
|
|
68
|
+
for f in msg.fields
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Check user's spec overlay for custom message types
|
|
73
|
+
overlay = overlay_store.get_overlay(user.uid)
|
|
74
|
+
field_defs = get_fields(begin_string)
|
|
75
|
+
for om in overlay.get("messages", []):
|
|
76
|
+
if om.get("msg_type") != msg_type:
|
|
77
|
+
continue
|
|
78
|
+
fields = []
|
|
79
|
+
for f in om.get("fields", []):
|
|
80
|
+
tag = f.get("tag", 0)
|
|
81
|
+
fd = field_defs.get(tag)
|
|
82
|
+
fields.append(FieldRefResponse(
|
|
83
|
+
tag=tag,
|
|
84
|
+
name=fd.name if fd else f"Tag{tag}",
|
|
85
|
+
type=fd.type if fd else "STRING",
|
|
86
|
+
required=f.get("required", False),
|
|
87
|
+
values=fd.values if fd else {},
|
|
88
|
+
))
|
|
89
|
+
return MessageDefResponse(
|
|
90
|
+
msg_type=msg_type,
|
|
91
|
+
name=om["name"],
|
|
92
|
+
category=om.get("category", "app"),
|
|
93
|
+
fields=fields,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
raise HTTPException(status_code=404, detail=f"Message type '{msg_type}' not found in {begin_string}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Annotated, Any
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from ...core.spec_overlay_store import SpecOverlayStore
|
|
7
|
+
from ...core.user_store import User
|
|
8
|
+
from ..deps import get_current_user, get_spec_overlay_store
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["spec-overlay"])
|
|
11
|
+
|
|
12
|
+
SO = Annotated[SpecOverlayStore, Depends(get_spec_overlay_store)]
|
|
13
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OverlayField(BaseModel):
|
|
17
|
+
tag: int
|
|
18
|
+
required: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OverlayMessage(BaseModel):
|
|
22
|
+
msg_type: str
|
|
23
|
+
name: str
|
|
24
|
+
category: str = "app"
|
|
25
|
+
fields: list[OverlayField] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpecOverlayRequest(BaseModel):
|
|
29
|
+
messages: list[OverlayMessage] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("")
|
|
33
|
+
def get_overlay(store: SO, user: CurrentUser) -> dict:
|
|
34
|
+
return store.get_overlay(user.uid)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.put("")
|
|
38
|
+
def set_overlay(body: SpecOverlayRequest, store: SO, user: CurrentUser) -> dict:
|
|
39
|
+
return store.set_overlay(user.uid, body.model_dump())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from threading import Lock
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomTagStore:
|
|
9
|
+
def __init__(self, data_dir: str) -> None:
|
|
10
|
+
self._data_dir = data_dir
|
|
11
|
+
self._lock = Lock()
|
|
12
|
+
|
|
13
|
+
def _path(self, uid: str) -> str:
|
|
14
|
+
return os.path.join(self._data_dir, uid, "custom_tags.json")
|
|
15
|
+
|
|
16
|
+
def _load(self, uid: str) -> dict[int, dict]:
|
|
17
|
+
try:
|
|
18
|
+
with open(self._path(uid)) as f:
|
|
19
|
+
return {int(k): v for k, v in json.load(f).items()}
|
|
20
|
+
except (FileNotFoundError, json.JSONDecodeError, ValueError):
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
def _save(self, uid: str, tags: dict[int, dict]) -> None:
|
|
24
|
+
path = self._path(uid)
|
|
25
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
26
|
+
tmp = path + ".tmp"
|
|
27
|
+
with open(tmp, "w") as f:
|
|
28
|
+
json.dump({str(k): v for k, v in tags.items()}, f, indent=2)
|
|
29
|
+
os.replace(tmp, path)
|
|
30
|
+
|
|
31
|
+
def list_tags(self, uid: str) -> list[dict]:
|
|
32
|
+
with self._lock:
|
|
33
|
+
tags = self._load(uid)
|
|
34
|
+
return sorted(
|
|
35
|
+
[{"number": n, **v} for n, v in tags.items()],
|
|
36
|
+
key=lambda x: x["number"],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def upsert_tag(self, uid: str, number: int, name: str, type_: str, values: dict[str, str]) -> dict:
|
|
40
|
+
with self._lock:
|
|
41
|
+
tags = self._load(uid)
|
|
42
|
+
tags[number] = {"name": name, "type": type_, "values": values}
|
|
43
|
+
self._save(uid, tags)
|
|
44
|
+
return {"number": number, "name": name, "type": type_, "values": values}
|
|
45
|
+
|
|
46
|
+
def delete_tag(self, uid: str, number: int) -> bool:
|
|
47
|
+
with self._lock:
|
|
48
|
+
tags = self._load(uid)
|
|
49
|
+
if number not in tags:
|
|
50
|
+
return False
|
|
51
|
+
del tags[number]
|
|
52
|
+
self._save(uid, tags)
|
|
53
|
+
return True
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from threading import Lock
|
|
6
|
+
|
|
7
|
+
_EMPTY: dict = {"messages": []}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpecOverlayStore:
|
|
11
|
+
def __init__(self, data_dir: str) -> None:
|
|
12
|
+
self._data_dir = data_dir
|
|
13
|
+
self._lock = Lock()
|
|
14
|
+
|
|
15
|
+
def _path(self, uid: str) -> str:
|
|
16
|
+
return os.path.join(self._data_dir, uid, "spec_overlay.json")
|
|
17
|
+
|
|
18
|
+
def get_overlay(self, uid: str) -> dict:
|
|
19
|
+
try:
|
|
20
|
+
with open(self._path(uid)) as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
23
|
+
return dict(_EMPTY)
|
|
24
|
+
|
|
25
|
+
def set_overlay(self, uid: str, overlay: dict) -> dict:
|
|
26
|
+
path = self._path(uid)
|
|
27
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
28
|
+
tmp = path + ".tmp"
|
|
29
|
+
with self._lock:
|
|
30
|
+
with open(tmp, "w") as f:
|
|
31
|
+
json.dump(overlay, f, indent=2)
|
|
32
|
+
os.replace(tmp, path)
|
|
33
|
+
return overlay
|