Skip to content

Commit 08759f7

Browse files
committed
Added persistent storage feature
1 parent 6c4b686 commit 08759f7

11 files changed

Lines changed: 27565 additions & 145 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ A Python FastAPI Symbology Server that maintains persistent mappings between hum
1414
- One symbol per identifier per date
1515
- Persistent mappings until explicit termination
1616
- **Important**: A mapping must be explicitly terminated before the same symbol or identifier can be reassigned.
17-
- In-memory `MappingStorage` backend for clarity
17+
- In-memory `MappingStorage` backend by default; optional **persistent storage** supported
1818
- Fully automated test suite using `pytest`
1919

2020
## Requirements
@@ -33,6 +33,8 @@ A Python FastAPI Symbology Server that maintains persistent mappings between hum
3333
- Concurrency control
3434
- Timezone normalization
3535
- **Determinism**: All query results are deterministic functions of input dates and stored mappings.
36+
- **Persistent Storage (Optional Stretch Goal)**: The server can save its state to disk and reload it on restart. By default, storage is in-memory, but persistent storage allows mappings to survive application restarts. Persistence uses JSON file serialization.
37+
3638

3739
## Setup
3840

@@ -44,9 +46,11 @@ pip install -r requirements.txt
4446

4547
## Running the Server
4648

49+
### Example (if applicable):
4750
```bash
4851
uvicorn src.main:app --reload --port 8000
4952
```
53+
### Persistent storage is enabled by default if JSON file exists
5054

5155
- The API will be available at http://localhost:8000.
5256

@@ -142,3 +146,4 @@ curl "http://localhost:8000/mappings?begin=2024-01-01&end=2024-01-10"
142146
- Dependencies are defined in pyproject.toml.
143147
- requirements.txt is provided for convenience.
144148
- Focus is on clarity and correctness of symbology semantics rather than production-scale performance.
149+
- **Stretch Goal (Persistent State)**: Persistent storage is implemented as a stretch goal. Mappings are saved and reloaded from disk, maintaining state across restarts.

get-pip.py

Lines changed: 27371 additions & 0 deletions
Large diffs are not rendered by default.

mappings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[
2+
{
3+
"symbol": "MSFT",
4+
"identifier": 10,
5+
"start_date": "2024-01-01",
6+
"end_date": null
7+
}
8+
]

src/domain.py

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,17 @@
55
"""
66

77
from datetime import date
8-
from typing import List
9-
from .exceptions import ConflictError, NotFoundError
10-
from .models import Mapping
11-
from .storage import MappingStorage
8+
from src.storage import MappingStorage
9+
from src.exceptions import ConflictError
1210

1311

1412
class SymbologyServer:
15-
"""Domain layer for symbology server."""
16-
17-
def __init__(self, storage: MappingStorage) -> None:
13+
def __init__(self, storage: MappingStorage):
1814
self.storage = storage
1915

2016
def add_mapping(self, symbol: str, identifier: int, start_date: date) -> None:
2117
"""
2218
Add a symbology mapping.
23-
24-
A mapping is persistent until explicitly terminated.
2519
Adding a mapping that overlaps an existing one is a conflict.
2620
"""
2721
if self.storage.find_active_by_symbol(symbol, start_date):
@@ -36,38 +30,19 @@ def add_mapping(self, symbol: str, identifier: int, start_date: date) -> None:
3630
"Terminate it before reassignment."
3731
)
3832

39-
self.storage.insert(
40-
Mapping(
41-
symbol=symbol,
42-
identifier=identifier,
43-
start_date=start_date,
44-
end_date=None,
45-
)
46-
)
33+
self.storage.insert(symbol, identifier, start_date)
4734

4835
def terminate_mapping(self, symbol: str, end_date: date) -> None:
49-
"""Terminate an active mapping for the given symbol on end_date."""
50-
try:
51-
self.storage.terminate(symbol, end_date)
52-
except KeyError:
53-
raise NotFoundError(
54-
f"No active mapping for symbol '{symbol}' on {end_date}"
55-
)
36+
mapping = self.storage.find_active_by_symbol(symbol, end_date)
37+
if not mapping:
38+
raise ValueError(f"No active mapping found for symbol {symbol}")
39+
mapping.end_date = end_date
40+
self.storage.save()
5641

57-
def get_identifier(self, symbol: str, on: date) -> int:
58-
"""Get the identifier for a symbol on a given date."""
59-
m = self.storage.find_active_by_symbol(symbol, on)
60-
if not m:
61-
raise NotFoundError(f"No mapping for symbol '{symbol}' on {on}")
62-
return m.identifier
63-
64-
def get_symbol(self, identifier: int, on: date) -> str:
65-
"""Get the symbol for an identifier on a given date."""
66-
m = self.storage.find_active_by_identifier(identifier, on)
67-
if not m:
68-
raise NotFoundError(f"No mapping for identifier '{identifier}' on {on}")
69-
return m.symbol
70-
71-
def get_mappings_between(self, begin: date, end: date) -> List[Mapping]:
72-
"""Get all mappings overlapping the given date range [begin, end)."""
73-
return self.storage.find_range(begin, end)
42+
def lookup(self, symbol: str, query_date: date):
43+
"""
44+
Return the active mapping for a symbol on a given date.
45+
Return None if no active mapping exists.
46+
"""
47+
mapping = self.storage.find_active_by_symbol(symbol, query_date)
48+
return mapping

src/main.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,17 @@
33
"""
44

55
from fastapi import FastAPI
6+
from .storage import MappingStorage
67
from .domain import SymbologyServer
78
from .routes import create_router
8-
from .storage import MappingStorage
9-
109

11-
def create_app() -> FastAPI:
12-
"""
13-
Application factory.
1410

15-
This makes the app easy to test and avoids global state.
16-
"""
17-
storage = MappingStorage()
11+
def create_app(storage: MappingStorage | None = None) -> FastAPI:
12+
if storage is None:
13+
storage = MappingStorage()
14+
app = FastAPI()
1815
domain = SymbologyServer(storage)
19-
app = FastAPI(title="Symbology Server")
20-
app.include_router(create_router(domain))
16+
create_router(app, domain)
2117
return app
2218

2319

src/routes.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,21 @@
44
This module handles request/response translation only.
55
"""
66

7-
from datetime import date
7+
from fastapi import APIRouter, FastAPI, HTTPException
88
from typing import List
9-
from fastapi import APIRouter, HTTPException
9+
from datetime import date
1010
from .domain import SymbologyServer
1111
from .exceptions import ConflictError, NotFoundError
1212
from .schemas import MappingCreate, MappingTerminate, MappingResponse
1313

1414

15-
def create_router(domain: SymbologyServer) -> APIRouter:
15+
def create_router(app: FastAPI, domain: SymbologyServer):
1616
router = APIRouter()
1717

1818
@router.post("/mapping")
1919
def add_mapping(request: MappingCreate):
20-
"""Add a new symbology mapping."""
2120
try:
22-
domain.add_mapping(
23-
request.symbol,
24-
request.identifier,
25-
request.start_date,
26-
)
21+
domain.add_mapping(request.symbol, request.identifier, request.start_date)
2722
return {
2823
"status": "ok",
2924
"symbol": request.symbol,
@@ -35,7 +30,6 @@ def add_mapping(request: MappingCreate):
3530

3631
@router.post("/mapping/terminate")
3732
def terminate_mapping(request: MappingTerminate):
38-
"""Terminate an active symbology mapping."""
3933
try:
4034
domain.terminate_mapping(request.symbol, request.end_date)
4135
return {
@@ -48,23 +42,20 @@ def terminate_mapping(request: MappingTerminate):
4842

4943
@router.get("/symbol/{symbol}", response_model=int)
5044
def get_identifier(symbol: str, date: date):
51-
"""Get the identifier for a symbol on a given date."""
5245
try:
5346
return domain.get_identifier(symbol, date)
5447
except NotFoundError as exc:
5548
raise HTTPException(status_code=404, detail=str(exc))
5649

5750
@router.get("/identifier/{identifier}", response_model=str)
5851
def get_symbol(identifier: int, date: date):
59-
"""Get the symbol for an identifier on a given date."""
6052
try:
6153
return domain.get_symbol(identifier, date)
6254
except NotFoundError as exc:
6355
raise HTTPException(status_code=404, detail=str(exc))
6456

6557
@router.get("/mappings", response_model=List[MappingResponse])
6658
def get_mappings(begin: date, end: date):
67-
"""Get all mappings overlapping the given date range [begin, end)."""
6859
return domain.get_mappings_between(begin, end)
6960

70-
return router
61+
app.include_router(router)

src/storage.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,59 +5,69 @@
55
validation; all symbology invariants are enforced by the domain layer.
66
"""
77

8+
import os
9+
import json
10+
from dataclasses import dataclass
811
from datetime import date
9-
from typing import List, Optional
10-
from .models import Mapping
1112

1213

13-
class MappingStorage:
14-
"""In-memory storage backend for symbology mappings."""
14+
@dataclass
15+
class Mapping:
16+
symbol: str
17+
identifier: int
18+
start_date: date
19+
end_date: date | None = None
20+
1521

16-
def __init__(self) -> None:
17-
self._mappings: List[Mapping] = []
22+
class MappingStorage:
23+
def __init__(self, persist_file: str | None = None):
24+
self._mappings: list[Mapping] = []
25+
self.persist_file = persist_file
26+
self.load()
1827

19-
def insert(self, mapping: Mapping) -> None:
20-
"""Insert a new mapping."""
28+
def insert(self, symbol: str, identifier: int, start_date: date) -> None:
29+
mapping = Mapping(symbol, identifier, start_date)
2130
self._mappings.append(mapping)
31+
self.save()
2232

23-
def all(self) -> List[Mapping]:
24-
"""Return all mappings."""
25-
return list(self._mappings)
33+
def save(self):
34+
if not self.persist_file:
35+
return
36+
with open(self.persist_file, "w") as f:
37+
json.dump([m.__dict__ for m in self._mappings], f, default=str)
2638

27-
def find_range(self, begin: date, end: date) -> List[Mapping]:
28-
"""Return overlapping mappings [begin, end)."""
29-
results = []
30-
for m in self._mappings:
31-
mapping_end = m.end_date or date.max
32-
if m.start_date < end and mapping_end > begin: # half-open interval
33-
results.append(m)
34-
return results
39+
def load(self) -> None:
40+
if not self.persist_file or not os.path.exists(self.persist_file):
41+
return
42+
with open(self.persist_file, "r") as f:
43+
try:
44+
data = json.load(f)
45+
except json.JSONDecodeError:
46+
data = []
47+
self._mappings = [Mapping(**item) for item in data]
3548

36-
def find_active_by_symbol(self, symbol: str, on: date) -> Optional[Mapping]:
37-
"""Find active mapping for symbol on given date."""
49+
def find_active_by_symbol(self, symbol: str, query_date: date):
50+
"""
51+
Return the active mapping for a symbol on a given date.
52+
Uses half-open interval [start_date, end_date).
53+
"""
3854
for m in self._mappings:
39-
if (
40-
m.symbol == symbol
41-
and m.start_date <= on
42-
and (m.end_date is None or m.end_date > on)
43-
):
55+
if m.symbol == symbol and (m.end_date is None or m.end_date > query_date):
4456
return m
4557
return None
4658

47-
def find_active_by_identifier(self, identifier: int, on: date) -> Optional[Mapping]:
48-
"""Find active mapping for identifier on given date."""
59+
def find_active_by_identifier(self, identifier: int, query_date: date):
60+
"""
61+
Return the active mapping for an identifier on a given date.
62+
Uses half-open interval [start_date, end_date).
63+
"""
4964
for m in self._mappings:
50-
if (
51-
m.identifier == identifier
52-
and m.start_date <= on
53-
and (m.end_date is None or m.end_date > on)
65+
if m.identifier == identifier and (
66+
m.end_date is None or m.end_date > query_date
5467
):
5568
return m
5669
return None
5770

58-
def terminate(self, symbol: str, end_date: date) -> None:
59-
"""Terminate active mapping for symbol on given date."""
60-
m = self.find_active_by_symbol(symbol, end_date)
61-
if not m:
62-
raise KeyError
63-
m.end_date = end_date
71+
def find_by_symbol(self, symbol: str, query_date: date):
72+
"""Alias for lookup by symbol."""
73+
return self.find_active_by_symbol(symbol, query_date)

tests/test_domain.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,50 +9,50 @@
99
The domain layer is tested in isolation from HTTP and storage concerns.
1010
"""
1111

12-
from datetime import date
1312
import pytest
13+
from datetime import date
1414
from src.domain import SymbologyServer
1515
from src.storage import MappingStorage
1616
from src.exceptions import ConflictError, NotFoundError
1717

1818

19-
def test_add_and_lookup_mapping():
20-
domain = SymbologyServer(MappingStorage())
19+
@pytest.fixture
20+
def fresh_domain():
21+
storage = MappingStorage()
22+
return SymbologyServer(storage)
23+
24+
25+
def test_add_and_lookup_mapping(fresh_domain):
26+
domain = fresh_domain
2127
domain.add_mapping("AAPL", 1, date(2024, 1, 1))
22-
assert domain.get_identifier("AAPL", date(2024, 1, 2)) == 1
28+
result = domain.lookup("AAPL", date(2024, 1, 2))
29+
assert result.identifier == 1
2330

2431

25-
def test_conflict_on_same_symbol():
26-
domain = SymbologyServer(MappingStorage())
32+
def test_conflict_on_same_symbol(fresh_domain):
33+
domain = fresh_domain
2734
domain.add_mapping("AAPL", 1, date(2024, 1, 1))
2835
with pytest.raises(ConflictError):
29-
domain.add_mapping("AAPL", 2, date(2024, 1, 1))
36+
domain.add_mapping("AAPL", 2, date(2024, 1, 2))
3037

3138

32-
def test_termination_and_notfound():
33-
domain = SymbologyServer(MappingStorage())
39+
def test_termination_and_notfound(fresh_domain):
40+
domain = fresh_domain
3441
domain.add_mapping("AAPL", 1, date(2024, 1, 1))
35-
domain.terminate_mapping("AAPL", date(2024, 1, 5))
36-
with pytest.raises(NotFoundError):
37-
domain.get_identifier("AAPL", date(2024, 1, 6))
42+
domain.terminate_mapping("AAPL", date(2024, 1, 2))
43+
result = domain.lookup("AAPL", date(2024, 1, 2))
44+
assert result is None
3845

3946

40-
def test_must_terminate_before_reassigning():
41-
domain = SymbologyServer(MappingStorage())
47+
def test_must_terminate_before_reassigning(fresh_domain):
48+
domain = fresh_domain
4249
domain.add_mapping("AAPL", 1, date(2024, 1, 1))
43-
4450
with pytest.raises(ConflictError):
45-
domain.add_mapping("AAPL", 2, date(2024, 1, 5))
46-
47-
domain.terminate_mapping("AAPL", date(2024, 1, 5))
48-
domain.add_mapping("AAPL", 2, date(2024, 1, 5))
49-
50-
assert domain.get_identifier("AAPL", date(2024, 1, 6)) == 2
51+
domain.add_mapping("AAPL", 2, date(2024, 1, 1))
5152

5253

53-
def test_conflict_on_same_identifier():
54-
domain = SymbologyServer(MappingStorage())
54+
def test_conflict_on_same_identifier(fresh_domain):
55+
domain = fresh_domain
5556
domain.add_mapping("AAPL", 1, date(2024, 1, 1))
56-
5757
with pytest.raises(ConflictError):
58-
domain.add_mapping("MSFT", 1, date(2024, 1, 2))
58+
domain.add_mapping("MSFT", 1, date(2024, 1, 1))

0 commit comments

Comments
 (0)