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 ADDED
@@ -0,0 +1,5 @@
1
+ """Offline-first client and local mirror for the PBS Public Data API v3."""
2
+
3
+ from pbs_client.config import PBSClientConfig, PBSSettings, get_pbs_context
4
+
5
+ __all__ = ["PBSClientConfig", "PBSSettings", "get_pbs_context"]
@@ -0,0 +1,6 @@
1
+ """pbs-client command line entry point."""
2
+
3
+ from pbs_client.cli.main import app, main
4
+
5
+ __all__ = ["app", "main"]
6
+
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
+ ]
@@ -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)