Skip to content

Commit 999bb07

Browse files
authored
Merge pull request #40 from Perseus-Computing-LLC/fix/polish
Polish: strict int parsing, --db wiring, config backups, email_verified, yaml fallback (#37)
2 parents 9da3d4e + 842ece1 commit 999bb07

7 files changed

Lines changed: 265 additions & 10 deletions

File tree

plutus_agent/cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ def build_parser():
379379
p = argparse.ArgumentParser(
380380
prog="plutus", description=f"Plutus — {__tagline__}")
381381
p.add_argument("--version", action="version", version=f"plutus v{__version__}")
382+
p.add_argument("--db", help="database path (overrides PLUTUS_DB)")
382383
sub = p.add_subparsers(dest="cmd")
383384

384385
pi = sub.add_parser("init", help="create config + database")
@@ -480,6 +481,9 @@ def main(argv=None):
480481
_force_utf8()
481482
parser = build_parser()
482483
args = parser.parse_args(argv)
484+
# Wire --db through to PLUTUS_DB env var so all db.connect() calls honor it
485+
if hasattr(args, 'db') and args.db:
486+
os.environ['PLUTUS_DB'] = args.db
483487
if not getattr(args, "func", None):
484488
parser.print_help()
485489
return 0

plutus_agent/config.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,52 @@ def _minimal_yaml_read(path: Path) -> dict:
127127
"""Tiny fallback reader (flat key: value, one level of nesting by indent).
128128
129129
Only used if PyYAML is missing. Good enough to read a config we wrote.
130+
Supports simple YAML (key: value, one level of 2-space-indented nesting)
131+
and JSON as a fallback.
130132
"""
131133
try:
132-
import json
133134
text = path.read_text(encoding="utf-8")
134-
# we may have written JSON as a fallback
135+
# Try JSON first if it looks like JSON
135136
if text.lstrip().startswith("{"):
137+
import json
136138
return json.loads(text)
139+
140+
# Parse simple YAML: 'key: value' and one level of ' nested: value'
141+
result = {}
142+
current_section = None
143+
for line in text.splitlines():
144+
stripped = line.rstrip()
145+
if not stripped or stripped.startswith("#"):
146+
continue
147+
148+
# Top-level key (no leading spaces)
149+
if not line.startswith(" ") and ":" in stripped:
150+
key, _, val = stripped.partition(":")
151+
key = key.strip()
152+
val = val.strip()
153+
if val:
154+
# Simple value: try to parse as JSON literal, else string
155+
try:
156+
import json
157+
result[key] = json.loads(val)
158+
except (json.JSONDecodeError, ValueError):
159+
result[key] = val
160+
else:
161+
# Empty value means nested section follows
162+
result[key] = {}
163+
current_section = key
164+
# Nested key (2-space indent)
165+
elif line.startswith(" ") and ":" in stripped and current_section:
166+
key, _, val = stripped.strip().partition(":")
167+
key = key.strip()
168+
val = val.strip()
169+
if val:
170+
try:
171+
import json
172+
result[current_section][key] = json.loads(val)
173+
except (json.JSONDecodeError, ValueError):
174+
result[current_section][key] = val
175+
return result
137176
except Exception:
138177
pass
139178
return {}
@@ -231,8 +270,18 @@ def _strip_env_secrets(cfg: dict) -> dict:
231270
def save(cfg: dict) -> Path:
232271
"""Persist config to disk. Secrets that came from the environment are
233272
stripped first (see :func:`_strip_env_secrets`) — ``config.yaml`` should
234-
never hold a live key that was provided via env."""
273+
never hold a live key that was provided via env.
274+
275+
Creates a timestamped backup of the existing config before overwriting.
276+
"""
235277
path = config_path()
278+
# Create timestamped backup if file already exists
279+
if path.exists():
280+
from datetime import datetime
281+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
282+
backup_path = path.with_suffix(f".yaml.bak-{timestamp}")
283+
import shutil
284+
shutil.copy2(path, backup_path)
236285
_dump_yaml(path, _strip_env_secrets(cfg))
237286
return path
238287

plutus_agent/db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
CREATE TABLE IF NOT EXISTS alerts_log (
8989
id TEXT PRIMARY KEY,
9090
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
91-
workspace_id TEXT,
91+
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
9292
kind TEXT NOT NULL, -- low_balance|budget_warn|budget_cap
9393
message TEXT NOT NULL,
9494
delivered INTEGER NOT NULL DEFAULT 0,

plutus_agent/server/app.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from .. import __version__, bridge, config as cfgmod, db
1717
from ..billing import StripeClient, BillingError, handle_webhook_event
18+
from ..utils import strict_int
1819
from . import api, views, auth as authmod
1920

2021
# Paths reachable without a session when auth is enabled.
@@ -317,10 +318,10 @@ def _ingest_usage(self, conn):
317318
conn, org_id, provider=str(ev["provider"]),
318319
model=ev.get("model"), task_type=ev.get("task_type", "general"),
319320
workspace=ev.get("workspace"),
320-
input_tokens=int(ev.get("input_tokens", 0) or 0),
321-
output_tokens=int(ev.get("output_tokens", 0) or 0),
322-
cache_read_tokens=int(ev.get("cache_read_tokens", 0) or 0),
323-
reasoning_tokens=int(ev.get("reasoning_tokens", 0) or 0),
321+
input_tokens=strict_int(ev.get("input_tokens", 0) or 0),
322+
output_tokens=strict_int(ev.get("output_tokens", 0) or 0),
323+
cache_read_tokens=strict_int(ev.get("cache_read_tokens", 0) or 0),
324+
reasoning_tokens=strict_int(ev.get("reasoning_tokens", 0) or 0),
324325
cost_usd=ev.get("cost_usd"),
325326
source=ev.get("source", "api"),
326327
pricing_overrides=cfg.get("pricing", {}).get("overrides"),

plutus_agent/server/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def _claims_from_id_token(id_token: str, cfg, nonce: str) -> dict:
135135
raise AuthError("id_token nonce mismatch")
136136
if not claims.get("email"):
137137
raise AuthError("id_token has no email")
138-
if claims.get("email_verified") in (False, "false"):
138+
if claims.get("email_verified") not in (True, "true"):
139139
raise AuthError("email is not verified")
140140
return claims
141141

plutus_agent/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Utility functions for Plutus."""
2+
from __future__ import annotations
3+
4+
5+
def strict_int(value) -> int:
6+
"""Parse an integer strictly, rejecting floats and non-integer strings.
7+
8+
Accepts:
9+
- int: 5, 0, -10
10+
- str with integer value: '5', '0', '-10'
11+
12+
Rejects:
13+
- float: 1.9, 1.0
14+
- str with float: '1.9', '1.0'
15+
16+
Raises:
17+
ValueError: if value is a float or contains a decimal point
18+
TypeError: if value cannot be converted to int
19+
"""
20+
if isinstance(value, float):
21+
raise ValueError(f"strict_int: float {value} not allowed (would truncate)")
22+
if isinstance(value, str):
23+
if '.' in value:
24+
raise ValueError(f"strict_int: string '{value}' contains decimal point")
25+
return int(value)
26+
return int(value)

tests/test_engine.py

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88

99
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1010

11-
from plutus_agent import db, metering, pricing, demo, reports
11+
from plutus_agent import db, metering, pricing, demo, reports, config as cfgmod
1212
from plutus_agent.billing import handle_webhook_event
13+
from plutus_agent.utils import strict_int
14+
from plutus_agent.server import auth as authmod
1315

1416

1517
_PATHS = {} # id(conn) -> file path, since sqlite3.Connection forbids attrs
@@ -399,5 +401,178 @@ def test_hook_meters_payload(self):
399401
os.environ.pop("PLUTUS_ORG", None)
400402

401403

404+
class TestPolishIssue37(unittest.TestCase):
405+
"""Tests for the polish punch-list fixes (issue #37)."""
406+
407+
def test_strict_int_accepts_integers(self):
408+
"""strict_int accepts int and integer-valued strings."""
409+
self.assertEqual(strict_int(5), 5)
410+
self.assertEqual(strict_int('5'), 5)
411+
self.assertEqual(strict_int(0), 0)
412+
self.assertEqual(strict_int('0'), 0)
413+
self.assertEqual(strict_int(-10), -10)
414+
self.assertEqual(strict_int('-10'), -10)
415+
416+
def test_strict_int_rejects_floats(self):
417+
"""strict_int rejects float values and float-bearing strings."""
418+
with self.assertRaises(ValueError):
419+
strict_int(1.9)
420+
with self.assertRaises(ValueError):
421+
strict_int('1.9')
422+
with self.assertRaises(ValueError):
423+
strict_int(1.0)
424+
with self.assertRaises(ValueError):
425+
strict_int('1.0')
426+
427+
def test_db_wiring_honors_env(self):
428+
"""db_path() honors PLUTUS_DB environment variable."""
429+
orig = os.environ.get('PLUTUS_DB')
430+
try:
431+
test_path = '/tmp/test_plutus_custom.db'
432+
os.environ['PLUTUS_DB'] = test_path
433+
self.assertEqual(str(cfgmod.db_path()), test_path)
434+
finally:
435+
if orig is None:
436+
os.environ.pop('PLUTUS_DB', None)
437+
else:
438+
os.environ['PLUTUS_DB'] = orig
439+
440+
def test_config_save_creates_backup(self):
441+
"""config.save() creates a timestamped backup when file exists."""
442+
import tempfile
443+
import shutil
444+
from pathlib import Path
445+
446+
home = tempfile.mkdtemp()
447+
orig_home = os.environ.get('PLUTUS_HOME')
448+
try:
449+
os.environ['PLUTUS_HOME'] = home
450+
cfg_path = cfgmod.config_path()
451+
452+
# First save: no backup should be created
453+
initial_cfg = {'test': 'value1'}
454+
cfgmod.save(initial_cfg)
455+
self.assertTrue(cfg_path.exists())
456+
backups = list(cfg_path.parent.glob('config.yaml.bak-*'))
457+
self.assertEqual(len(backups), 0, "No backup on first save")
458+
459+
# Second save: backup should be created
460+
import time
461+
time.sleep(1.1) # Ensure different timestamp
462+
updated_cfg = {'test': 'value2'}
463+
cfgmod.save(updated_cfg)
464+
backups = list(cfg_path.parent.glob('config.yaml.bak-*'))
465+
self.assertGreaterEqual(len(backups), 1, "At least one backup after second save")
466+
467+
# Third save: another backup
468+
time.sleep(1.1)
469+
cfgmod.save({'test': 'value3'})
470+
backups = list(cfg_path.parent.glob('config.yaml.bak-*'))
471+
self.assertGreaterEqual(len(backups), 2, "At least two backups after third save")
472+
finally:
473+
if orig_home is None:
474+
os.environ.pop('PLUTUS_HOME', None)
475+
else:
476+
os.environ['PLUTUS_HOME'] = orig_home
477+
shutil.rmtree(home, ignore_errors=True)
478+
479+
def test_email_verified_requires_truthy(self):
480+
"""email_verified must be explicitly True or 'true', missing/False rejected."""
481+
cfg = {
482+
'auth': {
483+
'google_client_id': 'test-client-id',
484+
'google_client_secret': 'test-secret',
485+
'base_url': 'http://localhost:8420'
486+
}
487+
}
488+
489+
# Missing email_verified should raise
490+
claims_missing = {
491+
'aud': 'test-client-id',
492+
'iss': 'https://accounts.google.com',
493+
'exp': time.time() + 3600,
494+
'nonce': 'test-nonce',
495+
'email': 'test@example.com'
496+
# email_verified missing
497+
}
498+
with self.assertRaises(authmod.AuthError) as ctx:
499+
authmod._claims_from_id_token.__wrapped__ = authmod._claims_from_id_token
500+
# We need to mock just the claims extraction part
501+
# Since _claims_from_id_token expects a JWT, let's test directly
502+
import base64
503+
import json
504+
505+
def make_fake_token(claims):
506+
# Create a fake JWT (we won't verify signature)
507+
header = base64.urlsafe_b64encode(json.dumps({'alg': 'RS256'}).encode()).decode().rstrip('=')
508+
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=')
509+
return f"{header}.{payload}.fake_signature"
510+
511+
fake_token = make_fake_token(claims_missing)
512+
authmod._claims_from_id_token(fake_token, cfg, 'test-nonce')
513+
self.assertIn('not verified', str(ctx.exception))
514+
515+
# False should raise
516+
claims_false = claims_missing.copy()
517+
claims_false['email_verified'] = False
518+
with self.assertRaises(authmod.AuthError) as ctx:
519+
fake_token = make_fake_token(claims_false)
520+
authmod._claims_from_id_token(fake_token, cfg, 'test-nonce')
521+
self.assertIn('not verified', str(ctx.exception))
522+
523+
# True should pass
524+
claims_true = claims_missing.copy()
525+
claims_true['email_verified'] = True
526+
fake_token = make_fake_token(claims_true)
527+
result = authmod._claims_from_id_token(fake_token, cfg, 'test-nonce')
528+
self.assertEqual(result['email'], 'test@example.com')
529+
530+
# 'true' string should pass
531+
claims_str = claims_missing.copy()
532+
claims_str['email_verified'] = 'true'
533+
fake_token = make_fake_token(claims_str)
534+
result = authmod._claims_from_id_token(fake_token, cfg, 'test-nonce')
535+
self.assertEqual(result['email'], 'test@example.com')
536+
537+
def test_minimal_yaml_read_roundtrip(self):
538+
"""_minimal_yaml_read can parse simple YAML config."""
539+
import tempfile
540+
from pathlib import Path
541+
542+
fd, path_str = tempfile.mkstemp(suffix='.yaml')
543+
os.close(fd)
544+
path = Path(path_str)
545+
546+
try:
547+
# Write a simple YAML config
548+
yaml_content = """server:
549+
host: 127.0.0.1
550+
port: 8420
551+
billing:
552+
currency: usd
553+
stripe_price_pro: price_123
554+
alerts:
555+
enabled: false
556+
low_balance_usd: 10.0
557+
"""
558+
path.write_text(yaml_content, encoding='utf-8')
559+
560+
# Read it back with the minimal reader
561+
result = cfgmod._minimal_yaml_read(path)
562+
563+
# Verify structure
564+
self.assertIn('server', result)
565+
self.assertIn('billing', result)
566+
self.assertIn('alerts', result)
567+
self.assertEqual(result['server']['host'], '127.0.0.1')
568+
self.assertEqual(result['server']['port'], 8420)
569+
self.assertEqual(result['billing']['currency'], 'usd')
570+
self.assertEqual(result['billing']['stripe_price_pro'], 'price_123')
571+
self.assertEqual(result['alerts']['enabled'], False)
572+
self.assertEqual(result['alerts']['low_balance_usd'], 10.0)
573+
finally:
574+
path.unlink()
575+
576+
402577
if __name__ == "__main__":
403578
unittest.main()

0 commit comments

Comments
 (0)