pbs-client 0.1.0__py3-none-any.whl
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.
- pbs_client/__init__.py +5 -0
- pbs_client/cli/__init__.py +6 -0
- pbs_client/cli/main.py +127 -0
- pbs_client/config.py +107 -0
- pbs_client/db/__init__.py +27 -0
- pbs_client/db/engine.py +37 -0
- pbs_client/db/model/__init__.py +83 -0
- pbs_client/db/model/base.py +18 -0
- pbs_client/db/model/core.py +285 -0
- pbs_client/db/model/items.py +315 -0
- pbs_client/db/model/pricing.py +64 -0
- pbs_client/db/model/relationships.py +193 -0
- pbs_client/db/models.py +3 -0
- pbs_client/db/schema.py +190 -0
- pbs_client/db/state.py +41 -0
- pbs_client/errors.py +43 -0
- pbs_client/http/__init__.py +6 -0
- pbs_client/http/client.py +221 -0
- pbs_client/query/__init__.py +30 -0
- pbs_client/query/service.py +167 -0
- pbs_client/sync/__init__.py +6 -0
- pbs_client/sync/orchestrator.py +198 -0
- pbs_client-0.1.0.dist-info/METADATA +65 -0
- pbs_client-0.1.0.dist-info/RECORD +27 -0
- pbs_client-0.1.0.dist-info/WHEEL +4 -0
- pbs_client-0.1.0.dist-info/entry_points.txt +5 -0
- pbs_client-0.1.0.dist-info/licenses/NOTICE +6 -0
pbs_client/__init__.py
ADDED
pbs_client/cli/main.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Thin Typer command line interface over the library layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine import Engine
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pbs_client.config import PBSSettings, get_pbs_context
|
|
9
|
+
from pbs_client.db import init_db, make_engine, make_session_factory
|
|
10
|
+
from pbs_client.errors import PBSSyncError, PBSTransportError
|
|
11
|
+
from pbs_client.http import PBSClient
|
|
12
|
+
from pbs_client.sync import SyncOrchestrator, mirror_status
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Maintain a local offline mirror of the PBS Public Data API v3.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _runtime(db_path: str | None) -> tuple[PBSSettings, Engine, str]:
|
|
18
|
+
"""Resolve shared config, with ``--db-path`` retained as a local override."""
|
|
19
|
+
|
|
20
|
+
if db_path is not None:
|
|
21
|
+
settings = PBSSettings.from_env()
|
|
22
|
+
return settings, make_engine(db_path), db_path
|
|
23
|
+
|
|
24
|
+
config, database = get_pbs_context()
|
|
25
|
+
return PBSSettings.from_config(config), database.create_engine(future=True), config.pbs_db
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _find_transport_error(error: BaseException) -> PBSTransportError | None:
|
|
29
|
+
"""Find a transport failure retained in a wrapped sync exception."""
|
|
30
|
+
|
|
31
|
+
current: BaseException | None = error
|
|
32
|
+
while current is not None:
|
|
33
|
+
if isinstance(current, PBSTransportError):
|
|
34
|
+
return current
|
|
35
|
+
current = current.__cause__
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _report_sync_failure(error: PBSSyncError) -> None:
|
|
40
|
+
"""Print an actionable, traceback-free message for a failed CLI sync."""
|
|
41
|
+
|
|
42
|
+
resource = error.resource or "the current resource"
|
|
43
|
+
transport = _find_transport_error(error)
|
|
44
|
+
typer.echo(f"PBS sync paused on {resource}.", err=True)
|
|
45
|
+
if transport is not None and transport.timed_out:
|
|
46
|
+
typer.echo(
|
|
47
|
+
"The PBS API did not respond before the request timeout after "
|
|
48
|
+
f"{transport.attempts} attempts.",
|
|
49
|
+
err=True,
|
|
50
|
+
)
|
|
51
|
+
typer.echo(
|
|
52
|
+
"Try a smaller page size if the response is large; completed "
|
|
53
|
+
"resources are already saved.",
|
|
54
|
+
err=True,
|
|
55
|
+
)
|
|
56
|
+
limit = " --limit 1000"
|
|
57
|
+
else:
|
|
58
|
+
typer.echo(str(error), err=True)
|
|
59
|
+
typer.echo("Completed resources are already saved and can be resumed.", err=True)
|
|
60
|
+
limit = ""
|
|
61
|
+
typer.echo("Resume with:", err=True)
|
|
62
|
+
typer.echo(
|
|
63
|
+
f" uv run pbs-client sync --resource {resource} --resume-only{limit}",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("init-db")
|
|
69
|
+
def init_db_command(
|
|
70
|
+
db_path: str | None = typer.Option(None, "--db-path", help="SQLite path or SQLAlchemy URL."),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Create the local schema without making any network request."""
|
|
73
|
+
|
|
74
|
+
_, engine, database_name = _runtime(db_path)
|
|
75
|
+
init_db(engine)
|
|
76
|
+
typer.echo(f"Initialized PBS database {database_name!r}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("sync")
|
|
80
|
+
def sync_command(
|
|
81
|
+
resource: str | None = typer.Option(None, help="Only sync one resource, e.g. Item."),
|
|
82
|
+
db_path: str | None = typer.Option(None, "--db-path", help="SQLite path or SQLAlchemy URL."),
|
|
83
|
+
refresh: bool = typer.Option(
|
|
84
|
+
True, "--refresh/--resume-only", help="Refresh completed resources."
|
|
85
|
+
),
|
|
86
|
+
limit: int = typer.Option(100_000, min=1, help="API page size."),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Synchronize all PBS resources, or one resource, into the local mirror."""
|
|
89
|
+
|
|
90
|
+
settings, engine, _ = _runtime(db_path)
|
|
91
|
+
init_db(engine)
|
|
92
|
+
sessions = make_session_factory(engine)
|
|
93
|
+
try:
|
|
94
|
+
results = SyncOrchestrator(PBSClient(settings), sessions).run(
|
|
95
|
+
resource=resource,
|
|
96
|
+
limit=limit,
|
|
97
|
+
refresh_completed=refresh,
|
|
98
|
+
)
|
|
99
|
+
except PBSSyncError as exc:
|
|
100
|
+
_report_sync_failure(exc)
|
|
101
|
+
raise typer.Exit(code=1) from None
|
|
102
|
+
for result in results:
|
|
103
|
+
typer.echo(
|
|
104
|
+
f"{result.resource}: {result.status}; pages={result.pages}; "
|
|
105
|
+
f"records={result.records_written}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def status(
|
|
111
|
+
db_path: str | None = typer.Option(None, "--db-path", help="SQLite path or SQLAlchemy URL."),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Show checkpoint status and local row counts for every resource."""
|
|
114
|
+
|
|
115
|
+
_, engine, _ = _runtime(db_path)
|
|
116
|
+
init_db(engine)
|
|
117
|
+
with make_session_factory(engine)() as session:
|
|
118
|
+
for row in mirror_status(session):
|
|
119
|
+
completed = row["completed_at"].isoformat() if row["completed_at"] else "-"
|
|
120
|
+
typer.echo(
|
|
121
|
+
f"{row['resource']}: {row['status']}; rows={row['rows']}; "
|
|
122
|
+
f"page={row['page']}; completed={completed}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
app()
|
pbs_client/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""PBS client settings and oa-configurator integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, ClassVar
|
|
9
|
+
|
|
10
|
+
from oa_configurator import (
|
|
11
|
+
GenericDatabaseConfig,
|
|
12
|
+
PackageConfigBase,
|
|
13
|
+
RefTo,
|
|
14
|
+
ResolvedDatabase,
|
|
15
|
+
Resolver,
|
|
16
|
+
Sensitive,
|
|
17
|
+
load_stack_config,
|
|
18
|
+
)
|
|
19
|
+
from pydantic import Field
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://data-api.health.gov.au/pbs/api/v3"
|
|
23
|
+
DEFAULT_PUBLIC_KEY = "2384af7c667342ceb5a736fe29f1dc6b"
|
|
24
|
+
DEFAULT_RATE_LIMIT_SECONDS = 20.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PBSClientConfig(PackageConfigBase):
|
|
28
|
+
"""Typed ``[tools.pbs_client]`` configuration for the PBS mirror.
|
|
29
|
+
|
|
30
|
+
The database is a named generic database rather than a package-local path,
|
|
31
|
+
so another package—eventually Groundworkers—can resolve and use the same
|
|
32
|
+
persisted PBS mirror through oa-configurator.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
tool_name: ClassVar[str] = "pbs_client"
|
|
36
|
+
|
|
37
|
+
pbs_db: Annotated[str, RefTo(GenericDatabaseConfig)] = Field(
|
|
38
|
+
default="pbs_db",
|
|
39
|
+
description="Name of the generic database that stores the PBS mirror.",
|
|
40
|
+
)
|
|
41
|
+
subscription_key: Annotated[str, Sensitive()] = Field(
|
|
42
|
+
default=DEFAULT_PUBLIC_KEY,
|
|
43
|
+
description="PBS Public Data API subscription key.",
|
|
44
|
+
)
|
|
45
|
+
base_url: str = Field(
|
|
46
|
+
default=DEFAULT_BASE_URL,
|
|
47
|
+
description="PBS Public Data API v3 base URL.",
|
|
48
|
+
)
|
|
49
|
+
rate_limit_seconds: float = Field(
|
|
50
|
+
default=DEFAULT_RATE_LIMIT_SECONDS,
|
|
51
|
+
gt=0,
|
|
52
|
+
description="Minimum delay between PBS API requests in seconds.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_pbs_context() -> tuple[PBSClientConfig, ResolvedDatabase]:
|
|
57
|
+
"""Load the configured PBS client and resolve its mirror database.
|
|
58
|
+
|
|
59
|
+
Raises
|
|
60
|
+
------
|
|
61
|
+
RuntimeError
|
|
62
|
+
If the shared oa-configurator stack file has not been created yet.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
stack = load_stack_config()
|
|
67
|
+
except FileNotFoundError as exc:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"No pbs-client configuration found. "
|
|
70
|
+
"Run `omop-config configure pbs_client` to set it up."
|
|
71
|
+
) from exc
|
|
72
|
+
resolver = Resolver(stack)
|
|
73
|
+
config = resolver.resolve_package_config(PBSClientConfig)
|
|
74
|
+
database = resolver.resolve_database(config.pbs_db)
|
|
75
|
+
return config, database
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class PBSSettings:
|
|
80
|
+
"""Configuration used by the HTTP, database, and CLI layers."""
|
|
81
|
+
|
|
82
|
+
subscription_key: str = DEFAULT_PUBLIC_KEY
|
|
83
|
+
db_path: Path = Path("pbs_client.db")
|
|
84
|
+
base_url: str = DEFAULT_BASE_URL
|
|
85
|
+
rate_limit_seconds: float = DEFAULT_RATE_LIMIT_SECONDS
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_config(cls, config: PBSClientConfig) -> PBSSettings:
|
|
89
|
+
"""Build HTTP settings from the resolved package configuration."""
|
|
90
|
+
|
|
91
|
+
return cls(
|
|
92
|
+
subscription_key=config.subscription_key,
|
|
93
|
+
base_url=config.base_url.rstrip("/"),
|
|
94
|
+
rate_limit_seconds=config.rate_limit_seconds,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_env(cls) -> PBSSettings:
|
|
99
|
+
"""Build settings from the documented ``PBS_CLIENT_*`` variables."""
|
|
100
|
+
|
|
101
|
+
rate = os.getenv("PBS_CLIENT_RATE_LIMIT_SECONDS")
|
|
102
|
+
return cls(
|
|
103
|
+
subscription_key=os.getenv("PBS_CLIENT_SUBSCRIPTION_KEY", DEFAULT_PUBLIC_KEY),
|
|
104
|
+
db_path=Path(os.getenv("PBS_CLIENT_DB_PATH", "./pbs_client.db")),
|
|
105
|
+
base_url=os.getenv("PBS_CLIENT_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
|
|
106
|
+
rate_limit_seconds=float(rate) if rate else DEFAULT_RATE_LIMIT_SECONDS,
|
|
107
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Local SQLAlchemy mirror for PBS API resources."""
|
|
2
|
+
|
|
3
|
+
from pbs_client.db.engine import database_url, init_db, make_engine, make_session_factory
|
|
4
|
+
from pbs_client.db.model import Base
|
|
5
|
+
from pbs_client.db.schema import (
|
|
6
|
+
RESOURCE_BY_ENDPOINT,
|
|
7
|
+
RESOURCE_BY_NAME,
|
|
8
|
+
RESOURCE_SPECS,
|
|
9
|
+
SYNC_ORDER,
|
|
10
|
+
)
|
|
11
|
+
from pbs_client.db.state import SyncState
|
|
12
|
+
|
|
13
|
+
MODEL_BY_NAME = {spec.name: spec.model for spec in RESOURCE_SPECS}
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MODEL_BY_NAME",
|
|
17
|
+
"RESOURCE_BY_ENDPOINT",
|
|
18
|
+
"RESOURCE_BY_NAME",
|
|
19
|
+
"RESOURCE_SPECS",
|
|
20
|
+
"SYNC_ORDER",
|
|
21
|
+
"Base",
|
|
22
|
+
"SyncState",
|
|
23
|
+
"database_url",
|
|
24
|
+
"init_db",
|
|
25
|
+
"make_engine",
|
|
26
|
+
"make_session_factory",
|
|
27
|
+
]
|
pbs_client/db/engine.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Database engine and schema helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Engine, create_engine
|
|
8
|
+
from sqlalchemy.orm import sessionmaker
|
|
9
|
+
|
|
10
|
+
from pbs_client.db.model import Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def database_url(path_or_url: str | Path) -> str:
|
|
14
|
+
"""Return a SQLAlchemy URL for a filesystem path or an explicit URL."""
|
|
15
|
+
|
|
16
|
+
value = str(path_or_url)
|
|
17
|
+
if "://" in value:
|
|
18
|
+
return value
|
|
19
|
+
return f"sqlite:///{Path(value).expanduser()}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_engine(path_or_url: str | Path = "pbs_client.db") -> Engine:
|
|
23
|
+
"""Construct an engine without connecting or making network requests."""
|
|
24
|
+
|
|
25
|
+
return create_engine(database_url(path_or_url), future=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_session_factory(engine: Engine):
|
|
29
|
+
"""Return a configured SQLAlchemy session factory."""
|
|
30
|
+
|
|
31
|
+
return sessionmaker(bind=engine, expire_on_commit=False, future=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def init_db(engine: Engine) -> None:
|
|
35
|
+
"""Create the complete local mirror schema if it does not exist."""
|
|
36
|
+
|
|
37
|
+
Base.metadata.create_all(engine)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""PBS resource model package."""
|
|
2
|
+
|
|
3
|
+
from pbs_client.db.model.base import Base, PBSRecordMixin
|
|
4
|
+
from pbs_client.db.model.core import (
|
|
5
|
+
ATC,
|
|
6
|
+
ApiChangelog,
|
|
7
|
+
Container,
|
|
8
|
+
Copayment,
|
|
9
|
+
Criteria,
|
|
10
|
+
DispensingRule,
|
|
11
|
+
ExtemporaneousPreparation,
|
|
12
|
+
Fee,
|
|
13
|
+
Indication,
|
|
14
|
+
Organisation,
|
|
15
|
+
Parameter,
|
|
16
|
+
PrescribingTxt,
|
|
17
|
+
Program,
|
|
18
|
+
ProgramDispensingRules,
|
|
19
|
+
RestrictionText,
|
|
20
|
+
Schedule,
|
|
21
|
+
)
|
|
22
|
+
from pbs_client.db.model.items import (
|
|
23
|
+
ExtemporaneousDrugTariff,
|
|
24
|
+
ExtemporaneousIngredient,
|
|
25
|
+
Item,
|
|
26
|
+
ItemAmt,
|
|
27
|
+
ItemOverview,
|
|
28
|
+
Prescriber,
|
|
29
|
+
StandardFormulaPreparations,
|
|
30
|
+
)
|
|
31
|
+
from pbs_client.db.model.pricing import MarkupBand, SummaryOfChanges
|
|
32
|
+
from pbs_client.db.model.relationships import (
|
|
33
|
+
ContainerWholesalerRltd,
|
|
34
|
+
CriteriaParameterRltd,
|
|
35
|
+
ExPrepSfpRltd,
|
|
36
|
+
ItemAtcRltd,
|
|
37
|
+
ItemDispensingRuleRltd,
|
|
38
|
+
ItemOrganisationRltd,
|
|
39
|
+
ItemPrescribingTxtRltd,
|
|
40
|
+
ItemPricingEvent,
|
|
41
|
+
ItemRestrictionRltd,
|
|
42
|
+
RstrctnPrscrbngTxtRltd,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ATC",
|
|
47
|
+
"ApiChangelog",
|
|
48
|
+
"Base",
|
|
49
|
+
"Container",
|
|
50
|
+
"ContainerWholesalerRltd",
|
|
51
|
+
"Copayment",
|
|
52
|
+
"Criteria",
|
|
53
|
+
"CriteriaParameterRltd",
|
|
54
|
+
"DispensingRule",
|
|
55
|
+
"ExPrepSfpRltd",
|
|
56
|
+
"ExtemporaneousDrugTariff",
|
|
57
|
+
"ExtemporaneousIngredient",
|
|
58
|
+
"ExtemporaneousPreparation",
|
|
59
|
+
"Fee",
|
|
60
|
+
"Indication",
|
|
61
|
+
"Item",
|
|
62
|
+
"ItemAmt",
|
|
63
|
+
"ItemAtcRltd",
|
|
64
|
+
"ItemDispensingRuleRltd",
|
|
65
|
+
"ItemOrganisationRltd",
|
|
66
|
+
"ItemOverview",
|
|
67
|
+
"ItemPrescribingTxtRltd",
|
|
68
|
+
"ItemPricingEvent",
|
|
69
|
+
"ItemRestrictionRltd",
|
|
70
|
+
"MarkupBand",
|
|
71
|
+
"Organisation",
|
|
72
|
+
"PBSRecordMixin",
|
|
73
|
+
"Parameter",
|
|
74
|
+
"Prescriber",
|
|
75
|
+
"PrescribingTxt",
|
|
76
|
+
"Program",
|
|
77
|
+
"ProgramDispensingRules",
|
|
78
|
+
"RestrictionText",
|
|
79
|
+
"RstrctnPrscrbngTxtRltd",
|
|
80
|
+
"Schedule",
|
|
81
|
+
"StandardFormulaPreparations",
|
|
82
|
+
"SummaryOfChanges",
|
|
83
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Declarative base and shared PBS model concerns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
import sqlalchemy.orm as so
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Base(so.DeclarativeBase):
|
|
12
|
+
"""Declarative registry for the local PBS mirror."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PBSRecordMixin:
|
|
16
|
+
"""Retain the original API object for forward-compatible fields."""
|
|
17
|
+
|
|
18
|
+
raw_payload: so.Mapped[dict[str, Any]] = so.mapped_column(sa.JSON, nullable=False, default=dict)
|