bursar 0.0.1__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.
- bursar/__init__.py +141 -0
- bursar/__main__.py +419 -0
- bursar/allowance.py +177 -0
- bursar/breakdown.py +35 -0
- bursar/config.py +161 -0
- bursar/engine.py +257 -0
- bursar/events.py +134 -0
- bursar/expr.py +494 -0
- bursar/interface/__init__.py +1 -0
- bursar/interface/base.py +772 -0
- bursar/interface/memory.py +1945 -0
- bursar/interface/models.py +596 -0
- bursar/interface/postgres.py +1120 -0
- bursar/interface/supabase.py +1014 -0
- bursar/manager.py +1566 -0
- bursar/metrics.py +50 -0
- bursar/py.typed +0 -0
- bursar/sql/001_core_schema.sql +163 -0
- bursar/sql/002_credit_rpcs.sql +217 -0
- bursar/sql/003_pricing_config.sql +231 -0
- bursar/sql/004_plans.sql +377 -0
- bursar/sql/005_spend_caps.sql +105 -0
- bursar/sql/006_refunds_and_expiry.sql +360 -0
- bursar/sql/007_analytics.sql +327 -0
- bursar/sql/008_teams.sql +324 -0
- bursar/sql/009_deduct_and_leases.sql +993 -0
- bursar/sql/010_credit_tiers.sql +212 -0
- bursar/sql/011_lazy_expiry.sql +383 -0
- bursar/sql/012_feature_limits.sql +74 -0
- bursar/sql/__init__.py +13 -0
- bursar-0.0.1.dist-info/METADATA +432 -0
- bursar-0.0.1.dist-info/RECORD +35 -0
- bursar-0.0.1.dist-info/WHEEL +5 -0
- bursar-0.0.1.dist-info/entry_points.txt +2 -0
- bursar-0.0.1.dist-info/top_level.txt +1 -0
bursar/__init__.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""bursar — declarative credit calculation engine for AI SaaS platforms."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("bursar")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
from bursar.breakdown import CostBreakdown
|
|
11
|
+
from bursar.config import ConfigError, PricingConfig
|
|
12
|
+
from bursar.engine import PricingEngine
|
|
13
|
+
from bursar.events import CreditEvent, CreditEventEmitter
|
|
14
|
+
from bursar.expr import ExpressionError, evaluate_expression, validate_expression
|
|
15
|
+
from bursar.interface.base import (
|
|
16
|
+
CapabilityNotSupportedError,
|
|
17
|
+
CapReachedError,
|
|
18
|
+
FeatureLimitReachedError,
|
|
19
|
+
RefundError,
|
|
20
|
+
StoreError,
|
|
21
|
+
)
|
|
22
|
+
from bursar.interface.memory import MemoryStore
|
|
23
|
+
from bursar.interface.models import (
|
|
24
|
+
AddCreditsResult,
|
|
25
|
+
AddTeamMemberResult,
|
|
26
|
+
AggregateStatsRow,
|
|
27
|
+
AllowanceResult,
|
|
28
|
+
AvailableResult,
|
|
29
|
+
BalanceResult,
|
|
30
|
+
CanAffordResult,
|
|
31
|
+
CapCheckResult,
|
|
32
|
+
CheckFeatureResult,
|
|
33
|
+
CreateTeamResult,
|
|
34
|
+
CreditMetadata,
|
|
35
|
+
DailySpendRow,
|
|
36
|
+
DeductionResult,
|
|
37
|
+
FeatureLimit,
|
|
38
|
+
FeatureLimitResult,
|
|
39
|
+
GetUserPlanResult,
|
|
40
|
+
LeaseResult,
|
|
41
|
+
OperationPolicy,
|
|
42
|
+
PlanDefinition,
|
|
43
|
+
PricingConfigData,
|
|
44
|
+
PricingConfigResult,
|
|
45
|
+
RefundResult,
|
|
46
|
+
ReleaseResult,
|
|
47
|
+
SetupResult,
|
|
48
|
+
SetUserPlanResult,
|
|
49
|
+
SpendByModelRow,
|
|
50
|
+
SpendByUserRow,
|
|
51
|
+
SpendCap,
|
|
52
|
+
SweepResult,
|
|
53
|
+
Team,
|
|
54
|
+
TeamBalanceResult,
|
|
55
|
+
TeamDeductionResult,
|
|
56
|
+
TeamMember,
|
|
57
|
+
TierBalance,
|
|
58
|
+
TierBalancesResult,
|
|
59
|
+
TierDefinition,
|
|
60
|
+
TopUserRow,
|
|
61
|
+
TransactionRow,
|
|
62
|
+
)
|
|
63
|
+
from bursar.manager import (
|
|
64
|
+
ConcurrencyLimitError,
|
|
65
|
+
CreditError,
|
|
66
|
+
CreditManager,
|
|
67
|
+
FeatureNotEntitledError,
|
|
68
|
+
InsufficientCreditsError,
|
|
69
|
+
LeaseExpiredError,
|
|
70
|
+
LeaseNotFoundError,
|
|
71
|
+
LowBalanceConfig,
|
|
72
|
+
PricingNotLoadedError,
|
|
73
|
+
)
|
|
74
|
+
from bursar.metrics import ToolCall, UsageMetrics
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"AddCreditsResult",
|
|
78
|
+
"AddTeamMemberResult",
|
|
79
|
+
"AggregateStatsRow",
|
|
80
|
+
"AllowanceResult",
|
|
81
|
+
"AvailableResult",
|
|
82
|
+
"BalanceResult",
|
|
83
|
+
"CanAffordResult",
|
|
84
|
+
"CapCheckResult",
|
|
85
|
+
"CapabilityNotSupportedError",
|
|
86
|
+
"CapReachedError",
|
|
87
|
+
"CheckFeatureResult",
|
|
88
|
+
"ConcurrencyLimitError",
|
|
89
|
+
"ConfigError",
|
|
90
|
+
"CostBreakdown",
|
|
91
|
+
"CreateTeamResult",
|
|
92
|
+
"CreditError",
|
|
93
|
+
"CreditEvent",
|
|
94
|
+
"CreditEventEmitter",
|
|
95
|
+
"CreditManager",
|
|
96
|
+
"CreditMetadata",
|
|
97
|
+
"DailySpendRow",
|
|
98
|
+
"DeductionResult",
|
|
99
|
+
"ExpressionError",
|
|
100
|
+
"FeatureLimit",
|
|
101
|
+
"FeatureLimitReachedError",
|
|
102
|
+
"FeatureLimitResult",
|
|
103
|
+
"FeatureNotEntitledError",
|
|
104
|
+
"GetUserPlanResult",
|
|
105
|
+
"InsufficientCreditsError",
|
|
106
|
+
"LeaseExpiredError",
|
|
107
|
+
"LeaseNotFoundError",
|
|
108
|
+
"LeaseResult",
|
|
109
|
+
"LowBalanceConfig",
|
|
110
|
+
"MemoryStore",
|
|
111
|
+
"OperationPolicy",
|
|
112
|
+
"PlanDefinition",
|
|
113
|
+
"PricingConfig",
|
|
114
|
+
"PricingConfigData",
|
|
115
|
+
"PricingConfigResult",
|
|
116
|
+
"PricingEngine",
|
|
117
|
+
"PricingNotLoadedError",
|
|
118
|
+
"RefundError",
|
|
119
|
+
"RefundResult",
|
|
120
|
+
"ReleaseResult",
|
|
121
|
+
"SetupResult",
|
|
122
|
+
"SetUserPlanResult",
|
|
123
|
+
"SpendByModelRow",
|
|
124
|
+
"SpendByUserRow",
|
|
125
|
+
"SpendCap",
|
|
126
|
+
"StoreError",
|
|
127
|
+
"SweepResult",
|
|
128
|
+
"Team",
|
|
129
|
+
"TeamBalanceResult",
|
|
130
|
+
"TeamDeductionResult",
|
|
131
|
+
"TeamMember",
|
|
132
|
+
"TierBalance",
|
|
133
|
+
"TierBalancesResult",
|
|
134
|
+
"TierDefinition",
|
|
135
|
+
"ToolCall",
|
|
136
|
+
"TopUserRow",
|
|
137
|
+
"TransactionRow",
|
|
138
|
+
"UsageMetrics",
|
|
139
|
+
"evaluate_expression",
|
|
140
|
+
"validate_expression",
|
|
141
|
+
]
|
bursar/__main__.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""bursar CLI — database migrations and pricing-version management.
|
|
2
|
+
|
|
3
|
+
Built on :mod:`argparse` so flags, ``--help``, exit codes and type coercion are
|
|
4
|
+
handled by the stdlib rather than hand-rolled ``argv`` slicing.
|
|
5
|
+
|
|
6
|
+
Connection secrets are taken from the environment, never the command line:
|
|
7
|
+
|
|
8
|
+
* ``migrate`` reads ``DATABASE_URL`` (primary). A positional URL is accepted for
|
|
9
|
+
convenience but is discouraged — it leaks via ``ps``/shell history/CI logs.
|
|
10
|
+
* ``pricing`` reads ``SUPABASE_URL`` + ``SUPABASE_SERVICE_ROLE_KEY``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import difflib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
24
|
+
|
|
25
|
+
_T = TypeVar("_T")
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from bursar.interface.models import PricingConfigResult
|
|
29
|
+
from bursar.interface.supabase import HttpxSupabaseStore
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from dotenv import load_dotenv
|
|
33
|
+
except ImportError:
|
|
34
|
+
load_dotenv = None # type: ignore[assignment]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Retry tuning ────────────────────────────────────────────────────────────
|
|
38
|
+
# A freshly-applied migration may not be visible to PostgREST until its schema
|
|
39
|
+
# cache reloads. Only that *transient* condition is retried — never auth,
|
|
40
|
+
# validation, or a write that may have already committed server-side.
|
|
41
|
+
_RETRY_INITIAL_DELAY = 1.0
|
|
42
|
+
_RETRY_MAX_DELAY = 8.0
|
|
43
|
+
_RETRIES = 5
|
|
44
|
+
|
|
45
|
+
# Substrings that mark a transient PostgREST schema-cache / connectivity miss.
|
|
46
|
+
# These are matched case-insensitively against the StoreError message.
|
|
47
|
+
_TRANSIENT_MARKERS = (
|
|
48
|
+
"pgrst205", # PostgREST: requested function not found in schema cache
|
|
49
|
+
"pgrst204", # PostgREST: column not found in schema cache
|
|
50
|
+
"pgrst202", # PostgREST: function signature not found in schema cache
|
|
51
|
+
"schema cache",
|
|
52
|
+
"could not find the function",
|
|
53
|
+
"timed out",
|
|
54
|
+
"request error", # wrapped httpx.RequestError (connection refused/reset)
|
|
55
|
+
"connection",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_env() -> None:
|
|
60
|
+
"""Load ``.env`` from CWD. Existing environment variables win."""
|
|
61
|
+
env_path = Path.cwd() / ".env"
|
|
62
|
+
if env_path.is_file() and load_dotenv:
|
|
63
|
+
load_dotenv(env_path, override=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Extra name → top-level import names needed
|
|
67
|
+
_EXTRAS: dict[str, list[str]] = {
|
|
68
|
+
"postgres": ["psycopg2"],
|
|
69
|
+
"supabase": ["httpx"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_extra(extra: str) -> None:
|
|
74
|
+
"""Exit (code 1) with an install hint if any import for *extra* is missing."""
|
|
75
|
+
for mod in _EXTRAS.get(extra, []):
|
|
76
|
+
try:
|
|
77
|
+
__import__(mod)
|
|
78
|
+
except ImportError:
|
|
79
|
+
print(
|
|
80
|
+
f"bursar[{extra}] extra required (missing: {mod}). pip install bursar[{extra}]",
|
|
81
|
+
file=sys.stderr,
|
|
82
|
+
)
|
|
83
|
+
raise SystemExit(1) from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_transient(exc: Exception) -> bool:
|
|
87
|
+
"""True only for the PostgREST schema-cache / connection errors worth retrying."""
|
|
88
|
+
from bursar.interface.base import StoreError
|
|
89
|
+
|
|
90
|
+
if not isinstance(exc, StoreError):
|
|
91
|
+
return False
|
|
92
|
+
msg = str(exc).lower()
|
|
93
|
+
return any(marker in msg for marker in _TRANSIENT_MARKERS)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _retry_transient(op: Callable[[], _T], *, what: str) -> _T:
|
|
97
|
+
"""Run *op*, retrying ONLY transient PostgREST/connection errors (H7).
|
|
98
|
+
|
|
99
|
+
A non-transient error (auth, validation, a write that already committed)
|
|
100
|
+
is surfaced immediately so we never create a duplicate immutable pricing
|
|
101
|
+
version by blind-retrying a non-idempotent write.
|
|
102
|
+
"""
|
|
103
|
+
delay = _RETRY_INITIAL_DELAY
|
|
104
|
+
for attempt in range(_RETRIES):
|
|
105
|
+
try:
|
|
106
|
+
return op()
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
last = attempt == _RETRIES - 1
|
|
109
|
+
if last or not _is_transient(exc):
|
|
110
|
+
print(f"Failed to {what}: {exc}", file=sys.stderr)
|
|
111
|
+
if _is_transient(exc):
|
|
112
|
+
print(
|
|
113
|
+
"Tip: run 'bursar migrate' and wait for the PostgREST schema cache to refresh.",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
raise SystemExit(1) from exc
|
|
117
|
+
time.sleep(delay)
|
|
118
|
+
delay = min(delay * 2, _RETRY_MAX_DELAY)
|
|
119
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _store_from_env() -> HttpxSupabaseStore:
|
|
123
|
+
"""Create an :class:`HttpxSupabaseStore` from ``SUPABASE_*`` env vars."""
|
|
124
|
+
_require_extra("supabase")
|
|
125
|
+
|
|
126
|
+
url = os.environ.get("SUPABASE_URL")
|
|
127
|
+
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
|
|
128
|
+
if not url:
|
|
129
|
+
print("SUPABASE_URL required", file=sys.stderr)
|
|
130
|
+
raise SystemExit(1)
|
|
131
|
+
if not key:
|
|
132
|
+
print("SUPABASE_SERVICE_ROLE_KEY required", file=sys.stderr)
|
|
133
|
+
raise SystemExit(1)
|
|
134
|
+
|
|
135
|
+
from bursar.interface.supabase import HttpxSupabaseStore
|
|
136
|
+
|
|
137
|
+
return HttpxSupabaseStore(url=url, key=key)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── File loading ─────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_pricing_file(filepath: str) -> dict[str, Any]:
|
|
144
|
+
"""Read a JSON or YAML pricing config into a dict.
|
|
145
|
+
|
|
146
|
+
All failure modes (missing file, directory, permission denied, parse error,
|
|
147
|
+
empty/non-object payload) print a clean message to stderr and exit 1 — no
|
|
148
|
+
tracebacks (M12).
|
|
149
|
+
"""
|
|
150
|
+
is_yaml = filepath.endswith((".yaml", ".yml"))
|
|
151
|
+
|
|
152
|
+
if filepath == "-":
|
|
153
|
+
raw = sys.stdin.read()
|
|
154
|
+
data = _parse_pricing_text(raw, is_yaml=False, source="<stdin>")
|
|
155
|
+
else:
|
|
156
|
+
path = Path(filepath)
|
|
157
|
+
if path.is_dir():
|
|
158
|
+
print(f"Not a file (is a directory): {filepath}", file=sys.stderr)
|
|
159
|
+
raise SystemExit(1)
|
|
160
|
+
try:
|
|
161
|
+
raw = path.read_text()
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
print(f"File not found: {filepath}", file=sys.stderr)
|
|
164
|
+
raise SystemExit(1) from None
|
|
165
|
+
except PermissionError:
|
|
166
|
+
print(f"Permission denied: {filepath}", file=sys.stderr)
|
|
167
|
+
raise SystemExit(1) from None
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
print(f"Could not read {filepath}: {exc}", file=sys.stderr)
|
|
170
|
+
raise SystemExit(1) from None
|
|
171
|
+
data = _parse_pricing_text(raw, is_yaml=is_yaml, source=filepath)
|
|
172
|
+
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
print(f"Pricing config must be a JSON/YAML object, got {type(data).__name__}", file=sys.stderr)
|
|
175
|
+
raise SystemExit(1)
|
|
176
|
+
if not data:
|
|
177
|
+
print("Pricing config is empty.", file=sys.stderr)
|
|
178
|
+
raise SystemExit(1)
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_pricing_text(raw: str, *, is_yaml: bool, source: str) -> Any:
|
|
183
|
+
"""Parse *raw* as YAML or JSON, exiting 1 with a clean message on failure."""
|
|
184
|
+
if is_yaml:
|
|
185
|
+
try:
|
|
186
|
+
import yaml
|
|
187
|
+
except ImportError:
|
|
188
|
+
print("PyYAML required for .yaml files: pip install bursar[supabase]", file=sys.stderr)
|
|
189
|
+
raise SystemExit(1) from None
|
|
190
|
+
try:
|
|
191
|
+
return yaml.safe_load(raw)
|
|
192
|
+
except yaml.YAMLError as exc:
|
|
193
|
+
print(f"Invalid YAML in {source}: {exc}", file=sys.stderr)
|
|
194
|
+
raise SystemExit(1) from None
|
|
195
|
+
try:
|
|
196
|
+
return json.loads(raw)
|
|
197
|
+
except json.JSONDecodeError as exc:
|
|
198
|
+
print(f"Invalid JSON in {source}: {exc}", file=sys.stderr)
|
|
199
|
+
raise SystemExit(1) from None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Command handlers ─────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cmd_migrate(args: argparse.Namespace) -> None:
|
|
206
|
+
_require_extra("postgres")
|
|
207
|
+
|
|
208
|
+
# DATABASE_URL (env) is primary; a positional arg is a discouraged fallback.
|
|
209
|
+
database_url = os.environ.get("DATABASE_URL") or args.database_url
|
|
210
|
+
if args.database_url:
|
|
211
|
+
print(
|
|
212
|
+
"warning: passing the database URL on the command line leaks the password "
|
|
213
|
+
"via 'ps'/shell history/CI logs — prefer the DATABASE_URL env var.",
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
)
|
|
216
|
+
if not database_url:
|
|
217
|
+
print(
|
|
218
|
+
"No database URL. Set DATABASE_URL (recommended) or pass it positionally:\n"
|
|
219
|
+
" DATABASE_URL=postgresql://… bursar migrate",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
raise SystemExit(1)
|
|
223
|
+
|
|
224
|
+
from bursar.interface.supabase import run_migrations
|
|
225
|
+
|
|
226
|
+
result = run_migrations(database_url)
|
|
227
|
+
for t in result.tables_created:
|
|
228
|
+
print(f" ✓ {t}")
|
|
229
|
+
for e in result.errors:
|
|
230
|
+
print(f" ✗ {e}", file=sys.stderr)
|
|
231
|
+
|
|
232
|
+
if result.success:
|
|
233
|
+
print("Migration complete.")
|
|
234
|
+
else:
|
|
235
|
+
print("Migration completed with errors.", file=sys.stderr)
|
|
236
|
+
raise SystemExit(1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _cmd_pricing_validate(args: argparse.Namespace) -> None:
|
|
240
|
+
from bursar.config import PricingConfig
|
|
241
|
+
from bursar.interface.models import PricingConfigData
|
|
242
|
+
|
|
243
|
+
data = _load_pricing_file(args.file)
|
|
244
|
+
try:
|
|
245
|
+
PricingConfigData.model_validate(data)
|
|
246
|
+
PricingConfig.model_validate(data)
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
print(f"Validation failed: {exc}", file=sys.stderr)
|
|
249
|
+
raise SystemExit(1) from None
|
|
250
|
+
print("Pricing config is valid.")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _cmd_pricing_set(args: argparse.Namespace) -> None:
|
|
254
|
+
from bursar.interface.models import PricingConfigData
|
|
255
|
+
|
|
256
|
+
data = _load_pricing_file(args.file)
|
|
257
|
+
try:
|
|
258
|
+
config = PricingConfigData.model_validate(data)
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
print(f"Validation failed: {exc}", file=sys.stderr)
|
|
261
|
+
raise SystemExit(1) from None
|
|
262
|
+
|
|
263
|
+
store = _store_from_env()
|
|
264
|
+
_retry_transient(lambda: store.set_active_pricing(config, label=args.label), what="set pricing")
|
|
265
|
+
print("Pricing config set successfully.")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _cmd_pricing_get(_args: argparse.Namespace) -> None:
|
|
269
|
+
store = _store_from_env()
|
|
270
|
+
result = _retry_transient(store.get_active_pricing, what="get pricing")
|
|
271
|
+
if result is None:
|
|
272
|
+
print("No active pricing config.", file=sys.stderr)
|
|
273
|
+
raise SystemExit(1)
|
|
274
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _cmd_pricing_list(_args: argparse.Namespace) -> None:
|
|
278
|
+
store = _store_from_env()
|
|
279
|
+
rows = _retry_transient(store.get_pricing_history, what="list pricing")
|
|
280
|
+
if not rows:
|
|
281
|
+
print("No pricing configs found.", file=sys.stderr)
|
|
282
|
+
raise SystemExit(1)
|
|
283
|
+
for r in rows:
|
|
284
|
+
marker = "*" if r.active else " "
|
|
285
|
+
label = f" {r.label}" if r.label else ""
|
|
286
|
+
print(f" {marker} v{r.version} (id={r.id[:8]}...){label} {r.created_at[:19]}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _cmd_pricing_activate(args: argparse.Namespace) -> None:
|
|
290
|
+
store = _store_from_env()
|
|
291
|
+
_retry_transient(lambda: store.activate_pricing(args.version), what="activate pricing")
|
|
292
|
+
print(f"Pricing v{args.version} activated.")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _cmd_pricing_export(args: argparse.Namespace) -> None:
|
|
296
|
+
store = _store_from_env()
|
|
297
|
+
result = _retry_transient(lambda: store.get_pricing_config(args.version), what="fetch pricing")
|
|
298
|
+
if result is None:
|
|
299
|
+
print(f"Version {args.version} not found.", file=sys.stderr)
|
|
300
|
+
raise SystemExit(1)
|
|
301
|
+
print(json.dumps(result.config.model_dump(mode="json", exclude_none=True), indent=2))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _cmd_pricing_diff(args: argparse.Namespace) -> None:
|
|
305
|
+
store = _store_from_env()
|
|
306
|
+
|
|
307
|
+
def _fetch() -> tuple[PricingConfigResult | None, PricingConfigResult | None]:
|
|
308
|
+
return store.get_pricing_config(args.version_a), store.get_pricing_config(args.version_b)
|
|
309
|
+
|
|
310
|
+
a, b = _retry_transient(_fetch, what="fetch pricing configs")
|
|
311
|
+
if a is None:
|
|
312
|
+
print(f"Version {args.version_a} not found.", file=sys.stderr)
|
|
313
|
+
raise SystemExit(1)
|
|
314
|
+
if b is None:
|
|
315
|
+
print(f"Version {args.version_b} not found.", file=sys.stderr)
|
|
316
|
+
raise SystemExit(1)
|
|
317
|
+
|
|
318
|
+
a_json = json.dumps(a.config.model_dump(mode="json"), indent=2)
|
|
319
|
+
b_json = json.dumps(b.config.model_dump(mode="json"), indent=2)
|
|
320
|
+
diff = difflib.unified_diff(
|
|
321
|
+
a_json.splitlines(keepends=True),
|
|
322
|
+
b_json.splitlines(keepends=True),
|
|
323
|
+
fromfile=f"v{args.version_a}",
|
|
324
|
+
tofile=f"v{args.version_b}",
|
|
325
|
+
)
|
|
326
|
+
sys.stdout.writelines(diff)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── Parser construction ──────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
333
|
+
"""Build the top-level argument parser with subcommands."""
|
|
334
|
+
parser = argparse.ArgumentParser(
|
|
335
|
+
prog="bursar",
|
|
336
|
+
description="bursar — credit calculation engine: migrations & pricing management.",
|
|
337
|
+
)
|
|
338
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
339
|
+
|
|
340
|
+
# migrate
|
|
341
|
+
p_migrate = sub.add_parser(
|
|
342
|
+
"migrate",
|
|
343
|
+
help="Run database migrations (bursar[postgres])",
|
|
344
|
+
description=(
|
|
345
|
+
"Run bundled SQL migrations. The connection string is read from the "
|
|
346
|
+
"DATABASE_URL environment variable (recommended). A positional URL is "
|
|
347
|
+
"accepted but discouraged because it leaks the password via the process "
|
|
348
|
+
"list, shell history and CI logs."
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
p_migrate.add_argument(
|
|
352
|
+
"database_url",
|
|
353
|
+
nargs="?",
|
|
354
|
+
default=None,
|
|
355
|
+
metavar="DATABASE_URL",
|
|
356
|
+
help="(discouraged) Postgres URL; prefer the DATABASE_URL env var.",
|
|
357
|
+
)
|
|
358
|
+
p_migrate.set_defaults(func=_cmd_migrate)
|
|
359
|
+
|
|
360
|
+
# pricing
|
|
361
|
+
p_pricing = sub.add_parser(
|
|
362
|
+
"pricing",
|
|
363
|
+
help="Manage pricing config (bursar[supabase])",
|
|
364
|
+
description="Manage immutable pricing-config versions via the Supabase store.",
|
|
365
|
+
)
|
|
366
|
+
psub = p_pricing.add_subparsers(dest="subcommand", metavar="<subcommand>")
|
|
367
|
+
|
|
368
|
+
p_set = psub.add_parser("set", help="Apply config (always creates a new version)")
|
|
369
|
+
p_set.add_argument("file", help="JSON/YAML pricing file, or '-' for stdin")
|
|
370
|
+
p_set.add_argument("--label", default=None, help="Optional label/message for this version")
|
|
371
|
+
p_set.set_defaults(func=_cmd_pricing_set)
|
|
372
|
+
|
|
373
|
+
p_get = psub.add_parser("get", help="Show the active pricing config as JSON")
|
|
374
|
+
p_get.set_defaults(func=_cmd_pricing_get)
|
|
375
|
+
|
|
376
|
+
p_list = psub.add_parser("list", help="List all pricing versions (* = active)")
|
|
377
|
+
p_list.set_defaults(func=_cmd_pricing_list)
|
|
378
|
+
|
|
379
|
+
p_activate = psub.add_parser("activate", help="Switch the active version")
|
|
380
|
+
p_activate.add_argument("version", type=int, help="Version number to activate")
|
|
381
|
+
p_activate.set_defaults(func=_cmd_pricing_activate)
|
|
382
|
+
|
|
383
|
+
p_validate = psub.add_parser("validate", help="Validate a pricing file without applying it")
|
|
384
|
+
p_validate.add_argument("file", help="JSON/YAML pricing file, or '-' for stdin")
|
|
385
|
+
p_validate.set_defaults(func=_cmd_pricing_validate)
|
|
386
|
+
|
|
387
|
+
p_diff = psub.add_parser("diff", help="Unified diff between two versions")
|
|
388
|
+
p_diff.add_argument("version_a", type=int, help="First version")
|
|
389
|
+
p_diff.add_argument("version_b", type=int, help="Second version")
|
|
390
|
+
p_diff.set_defaults(func=_cmd_pricing_diff)
|
|
391
|
+
|
|
392
|
+
p_export = psub.add_parser("export", help="Dump a version as JSON")
|
|
393
|
+
p_export.add_argument("version", type=int, help="Version number to export")
|
|
394
|
+
p_export.set_defaults(func=_cmd_pricing_export)
|
|
395
|
+
|
|
396
|
+
p_pricing.set_defaults(_pricing_parser=p_pricing)
|
|
397
|
+
return parser
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main(argv: list[str] | None = None) -> None:
|
|
401
|
+
_load_env()
|
|
402
|
+
parser = build_parser()
|
|
403
|
+
args = parser.parse_args(argv)
|
|
404
|
+
|
|
405
|
+
if args.command is None:
|
|
406
|
+
parser.print_help()
|
|
407
|
+
raise SystemExit(1)
|
|
408
|
+
|
|
409
|
+
# `pricing` with no subcommand: show its help and exit non-zero.
|
|
410
|
+
if not hasattr(args, "func"):
|
|
411
|
+
sub_parser = getattr(args, "_pricing_parser", parser)
|
|
412
|
+
sub_parser.print_help(sys.stderr)
|
|
413
|
+
raise SystemExit(1)
|
|
414
|
+
|
|
415
|
+
args.func(args)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|