Amanda Torres commited on
Commit
db521fa
·
0 Parent(s):

initial commit

Browse files
config.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ _DEFAULTS: dict[str, Any] = {
10
+ "log_level": "INFO",
11
+ "debug": False,
12
+ "max_retries": 3,
13
+ "request_timeout": 30,
14
+ "page_size": 20,
15
+ "db_path": "data.db",
16
+ "secret_key": "",
17
+ }
18
+
19
+
20
+ class Config:
21
+ def __init__(self, path: Optional[str] = None) -> None:
22
+ self._data: dict[str, Any] = dict(_DEFAULTS)
23
+ if path and Path(path).exists():
24
+ self._load(path)
25
+ self._from_env()
26
+
27
+ def _load(self, path: str) -> None:
28
+ try:
29
+ with open(path) as f:
30
+ self._data.update(json.load(f))
31
+ except (json.JSONDecodeError, OSError) as exc:
32
+ logger.warning("Config load failed (%s): %s", path, exc)
33
+
34
+ def _from_env(self) -> None:
35
+ pairs = [
36
+ ("APP_DEBUG", "debug", lambda v: v.lower() == "true"),
37
+ ("APP_LOG_LEVEL", "log_level", str),
38
+ ("APP_DB_PATH", "db_path", str),
39
+ ("APP_SECRET_KEY","secret_key",str),
40
+ ]
41
+ for env, key, cast in pairs:
42
+ raw = os.environ.get(env)
43
+ if raw is not None:
44
+ try:
45
+ self._data[key] = cast(raw)
46
+ except (ValueError, TypeError):
47
+ pass
48
+
49
+ def get(self, key: str, default: Any = None) -> Any:
50
+ return self._data.get(key, default)
51
+
52
+ def __getattr__(self, key: str) -> Any:
53
+ if key.startswith("_"):
54
+ raise AttributeError(key)
55
+ try:
56
+ return self._data[key]
57
+ except KeyError:
58
+ raise AttributeError(key)
59
+
60
+
61
+ _cfg: Optional[Config] = None
62
+
63
+ def get_config() -> Config:
64
+ global _cfg
65
+ if _cfg is None:
66
+ _cfg = Config(os.environ.get("APP_CONFIG"))
67
+ return _cfg
exceptions.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Any, Optional
3
+
4
+
5
+ class AppError(Exception):
6
+ status_code: int = 500
7
+ code: str = "internal_error"
8
+
9
+ def __init__(self, message: str, details: Optional[Any] = None) -> None:
10
+ super().__init__(message)
11
+ self.details = details
12
+
13
+ def to_dict(self) -> dict:
14
+ out: dict = {"error": self.code, "message": str(self)}
15
+ if self.details is not None:
16
+ out["details"] = self.details
17
+ return out
18
+
19
+
20
+ class NotFoundError(AppError):
21
+ status_code = 404
22
+ code = "not_found"
23
+
24
+ def __init__(self, resource: str, identifier: Any = None) -> None:
25
+ msg = f"{resource} not found"
26
+ if identifier is not None:
27
+ msg += f": {identifier}"
28
+ super().__init__(msg)
29
+
30
+
31
+ class AuthError(AppError):
32
+ status_code = 401
33
+ code = "unauthorized"
34
+
35
+
36
+ class ForbiddenError(AppError):
37
+ status_code = 403
38
+ code = "forbidden"
39
+
40
+
41
+ class ValidationError(AppError):
42
+ status_code = 422
43
+ code = "validation_error"
44
+
45
+ def __init__(self, field: str, message: str) -> None:
46
+ self.field = field
47
+ super().__init__(f"{field}: {message}")
48
+
49
+ def to_dict(self) -> dict:
50
+ d = super().to_dict()
51
+ d["field"] = self.field
52
+ return d
53
+
54
+
55
+ class ConflictError(AppError):
56
+ status_code = 409
57
+ code = "conflict"
handlers/auth.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import secrets
3
+ import time
4
+ from typing import Optional
5
+
6
+ from utils import hash_password, verify_password
7
+ from exceptions import AuthError
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _SESSION_TTL = 3600
12
+ _MAX_ATTEMPTS = 5
13
+ _LOCKOUT = 300
14
+
15
+ _sessions: dict[str, dict] = {}
16
+ _failures: dict[str, list[float]] = {}
17
+
18
+
19
+ def _prune() -> None:
20
+ now = time.time()
21
+ dead = [t for t, s in _sessions.items() if now - s["ts"] > _SESSION_TTL]
22
+ for t in dead:
23
+ del _sessions[t]
24
+
25
+
26
+ def _locked(username: str) -> bool:
27
+ cutoff = time.time() - _LOCKOUT
28
+ recent = [t for t in _failures.get(username, []) if t > cutoff]
29
+ _failures[username] = recent
30
+ return len(recent) >= _MAX_ATTEMPTS
31
+
32
+
33
+ def login(username: str, password: str,
34
+ stored_digest: str, stored_salt: str) -> str:
35
+ if _locked(username):
36
+ raise AuthError("Account temporarily locked")
37
+ if not verify_password(password, stored_digest, stored_salt):
38
+ _failures.setdefault(username, []).append(time.time())
39
+ raise AuthError("Invalid credentials")
40
+ _prune()
41
+ token = secrets.token_hex(32)
42
+ _sessions[token] = {"username": username, "ts": time.time()}
43
+ logger.info("Login: %s", username)
44
+ return token
45
+
46
+
47
+ def logout(token: str) -> None:
48
+ s = _sessions.pop(token, None)
49
+ if s:
50
+ logger.info("Logout: %s", s["username"])
51
+
52
+
53
+ def whoami(token: str) -> Optional[str]:
54
+ s = _sessions.get(token)
55
+ if not s:
56
+ return None
57
+ if time.time() - s["ts"] > _SESSION_TTL:
58
+ del _sessions[token]
59
+ return None
60
+ return s["username"]
61
+
62
+
63
+ def require_auth(token: str) -> str:
64
+ user = whoami(token)
65
+ if user is None:
66
+ raise AuthError()
67
+ return user
68
+
69
+
70
+ def refresh(token: str) -> str:
71
+ user = require_auth(token)
72
+ logout(token)
73
+ new = secrets.token_hex(32)
74
+ _sessions[new] = {"username": user, "ts": time.time()}
75
+ return new
76
+
77
+
78
+ def session_count() -> int:
79
+ _prune()
80
+ return len(_sessions)
handlers/cli.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import sys
5
+ from typing import Any, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def _json(data: Any) -> None:
11
+ print(json.dumps(data, indent=2, default=str))
12
+
13
+
14
+ def _table(rows: list[dict], cols: Optional[list[str]] = None) -> None:
15
+ if not rows:
16
+ print("(empty)")
17
+ return
18
+ cols = cols or list(rows[0].keys())
19
+ widths = {c: max(len(c), max(len(str(r.get(c, ""))) for r in rows))
20
+ for c in cols}
21
+ sep = " "
22
+ print(sep.join(c.ljust(widths[c]) for c in cols))
23
+ print("-" * (sum(widths.values()) + len(sep) * (len(cols) - 1)))
24
+ for row in rows:
25
+ print(sep.join(str(row.get(c, "")).ljust(widths[c]) for c in cols))
26
+
27
+
28
+ def cmd_list(args: argparse.Namespace) -> int:
29
+ logger.debug("list: page=%d size=%d", args.page, args.size)
30
+ print(f"Listing (page={args.page} size={args.size})")
31
+ return 0
32
+
33
+
34
+ def cmd_add(args: argparse.Namespace) -> int:
35
+ logger.debug("add: name=%s tags=%s", args.name, args.tags)
36
+ print(f"Added: {args.name!r}")
37
+ return 0
38
+
39
+
40
+ def cmd_delete(args: argparse.Namespace) -> int:
41
+ if not args.yes:
42
+ confirm = input(f"Delete {args.id}? [y/N] ")
43
+ if confirm.lower() != "y":
44
+ print("Aborted.")
45
+ return 0
46
+ print(f"Deleted: {args.id}")
47
+ return 0
48
+
49
+
50
+ def cmd_show(args: argparse.Namespace) -> int:
51
+ print(f"Record: {args.id}")
52
+ return 0
53
+
54
+
55
+ def cmd_export(args: argparse.Namespace) -> int:
56
+ print(f"Exported as {args.format} to {args.output}")
57
+ return 0
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ p = argparse.ArgumentParser(prog="app",
62
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
63
+ p.add_argument("--json", dest="as_json", action="store_true")
64
+ sub = p.add_subparsers(dest="cmd", required=True)
65
+
66
+ ls = sub.add_parser("list")
67
+ ls.add_argument("--page", type=int, default=1)
68
+ ls.add_argument("--size", type=int, default=20)
69
+ ls.set_defaults(func=cmd_list)
70
+
71
+ add = sub.add_parser("add")
72
+ add.add_argument("name")
73
+ add.add_argument("--tag", action="append", dest="tags", default=[])
74
+ add.set_defaults(func=cmd_add)
75
+
76
+ rm = sub.add_parser("delete")
77
+ rm.add_argument("id")
78
+ rm.add_argument("-y", "--yes", action="store_true")
79
+ rm.set_defaults(func=cmd_delete)
80
+
81
+ show = sub.add_parser("show")
82
+ show.add_argument("id")
83
+ show.set_defaults(func=cmd_show)
84
+
85
+ exp = sub.add_parser("export")
86
+ exp.add_argument("--format", choices=["csv", "json", "text"], default="text")
87
+ exp.add_argument("--output", "-o", default="-")
88
+ exp.set_defaults(func=cmd_export)
89
+
90
+ return p
91
+
92
+
93
+ def run_cli() -> int:
94
+ args = build_parser().parse_args()
95
+ try:
96
+ return args.func(args)
97
+ except Exception as exc:
98
+ logger.error("%s", exc)
99
+ return 1
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(run_cli())
handlers/main.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import logging
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
7
+
8
+
9
+ def setup_logging(level: str, log_file: str | None = None) -> None:
10
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
11
+ if log_file:
12
+ handlers.append(logging.FileHandler(log_file))
13
+ logging.basicConfig(
14
+ level=getattr(logging, level.upper(), logging.INFO),
15
+ format=LOG_FORMAT,
16
+ handlers=handlers,
17
+ )
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ description="Application entry point",
23
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
24
+ )
25
+ parser.add_argument("--debug", action="store_true")
26
+ parser.add_argument("--log-file", default=None)
27
+ parser.add_argument("--config", default="config.json")
28
+ parser.add_argument("--dry-run", action="store_true")
29
+ return parser
30
+
31
+
32
+ def main() -> int:
33
+ parser = build_parser()
34
+ args = parser.parse_args()
35
+ setup_logging("DEBUG" if args.debug else "INFO", args.log_file)
36
+ logger = logging.getLogger(__name__)
37
+
38
+ if not Path(args.config).exists():
39
+ logger.warning("Config not found at %s, using defaults", args.config)
40
+
41
+ from app import App
42
+ try:
43
+ app = App(debug=args.debug, dry_run=args.dry_run)
44
+ app.run()
45
+ except KeyboardInterrupt:
46
+ logger.info("Interrupted")
47
+ return 130
48
+ except Exception as exc:
49
+ logger.exception("Fatal: %s", exc)
50
+ return 1
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())
56
+
57
+ aws_access_key_id = """
58
+ $ANSIBLE_VAULT;1.1;AES256
59
+ 66353764363130363936363637393563626631643934653435393131393335343734383235636433
60
+ 3938623731316532393039383331366232386365636265630a396138646634303961643335323434
61
+ 64323965316237666563633535323931323464353264623466333262626138666532393766386663
62
+ 6432306165316161370a393734613234313863373132313466663134313932313832643264366232
63
+ 33303063636265653834303431636637666362646233313238333661316163303935
64
+ """
65
+ aws_secret_access_key = """
66
+ $ANSIBLE_VAULT;1.1;AES256
67
+ 65666239326163313863396265313366336562643131356337333163316635336136353538356331
68
+ 6238393261343733373238373361336365323263646533640a623862306530373637363538363066
69
+ 62356632353937303361326432336166326430316431643231366230663535383437663639343331
70
+ 3435646630366333610a306164356265393332396565306435623964633033313437666366633366
71
+ 33636136343162313165353931343863393432356534326566626639646262613138313638386338
72
+ 6236626235383938623731636565376665656465383430353334
73
+ """
74
+ aws_region = "us-east-2"
75
+ aws_service_credentials = """
76
+ $ANSIBLE_VAULT;1.1;AES256
77
+ 31326133323131633531616466616464633462326234356539663831376663623431646435323936
78
+ 3035636332343263666561623862643133386162613566660a623961663630663738626164363164
79
+ 33663565656134363739303031306536356561313163626364343031353261346566333431623038
80
+ 3834623633386262650a633164316461666134656433646533343733356262333038333566356362
81
+ 35386262313731636634643064653531396361323763313061386330313266383161653366313664
82
+ 38636462663631353261656238396665336531393966373733343665666437653966353761623962
83
+ 66656132616437613061373865333463346361303961653231626135366237616363346266643630
84
+ 32323132323232666533336433373566386134643137623734626463346131643433373236663731
85
+ 32333035343636333637616531626231316534353664363366633434623464346530356432396661
86
+ 63663933653932386334306535376534356366656465316331646535303332643333343937353861
87
+ 346665386133376232623034316130643963
88
+ """
handlers/scheduler.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import threading
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Callable, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @dataclass
11
+ class Job:
12
+ name: str
13
+ fn: Callable
14
+ interval: float
15
+ max_runs: int = 0
16
+ on_error: Optional[Callable[[Exception], None]] = None
17
+ _runs: int = field(default=0, init=False, repr=False)
18
+ _last: float = field(default=0.0, init=False, repr=False)
19
+ _errors: int = field(default=0, init=False, repr=False)
20
+
21
+ def due(self, now: float) -> bool:
22
+ return now - self._last >= self.interval
23
+
24
+ def run(self) -> None:
25
+ try:
26
+ self.fn()
27
+ self._runs += 1
28
+ self._last = time.time()
29
+ logger.debug("Job '%s' run #%d OK", self.name, self._runs)
30
+ except Exception as exc:
31
+ self._errors += 1
32
+ logger.error("Job '%s' error #%d: %s", self.name, self._errors, exc)
33
+ if self.on_error:
34
+ try:
35
+ self.on_error(exc)
36
+ except Exception:
37
+ pass
38
+
39
+ def exhausted(self) -> bool:
40
+ return self.max_runs > 0 and self._runs >= self.max_runs
41
+
42
+ def stats(self) -> dict:
43
+ return {"name": self.name, "runs": self._runs,
44
+ "errors": self._errors, "last": self._last}
45
+
46
+
47
+ class Scheduler:
48
+ def __init__(self, tick: float = 1.0) -> None:
49
+ self._jobs: list[Job] = []
50
+ self._tick = tick
51
+ self._running = False
52
+ self._lock = threading.Lock()
53
+ self._thread: Optional[threading.Thread] = None
54
+
55
+ def add(self, name: str, fn: Callable, interval: float,
56
+ max_runs: int = 0, on_error: Optional[Callable] = None) -> Job:
57
+ job = Job(name=name, fn=fn, interval=interval,
58
+ max_runs=max_runs, on_error=on_error)
59
+ with self._lock:
60
+ self._jobs.append(job)
61
+ logger.info("Scheduled '%s' every %.1fs", name, interval)
62
+ return job
63
+
64
+ def remove(self, name: str) -> bool:
65
+ with self._lock:
66
+ before = len(self._jobs)
67
+ self._jobs = [j for j in self._jobs if j.name != name]
68
+ return len(self._jobs) < before
69
+
70
+ def _tick_once(self) -> None:
71
+ now = time.time()
72
+ with self._lock:
73
+ jobs = list(self._jobs)
74
+ done = []
75
+ for job in jobs:
76
+ if job.due(now):
77
+ job.run()
78
+ if job.exhausted():
79
+ done.append(job.name)
80
+ if done:
81
+ with self._lock:
82
+ self._jobs = [j for j in self._jobs if j.name not in done]
83
+
84
+ def _loop(self) -> None:
85
+ while self._running:
86
+ self._tick_once()
87
+ time.sleep(self._tick)
88
+
89
+ def start(self) -> None:
90
+ if self._running:
91
+ return
92
+ self._running = True
93
+ self._thread = threading.Thread(target=self._loop, daemon=True,
94
+ name="Scheduler")
95
+ self._thread.start()
96
+ logger.info("Scheduler started (tick=%.1fs)", self._tick)
97
+
98
+ def stop(self, timeout: float = 5.0) -> None:
99
+ self._running = False
100
+ if self._thread:
101
+ self._thread.join(timeout)
102
+ logger.info("Scheduler stopped")
103
+
104
+ def all_stats(self) -> list[dict]:
105
+ with self._lock:
106
+ return [j.stats() for j in self._jobs]
handlers/service.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime, timezone
3
+ from typing import Any, Optional
4
+
5
+ from models import Record, User
6
+ import database as db
7
+ from exceptions import NotFoundError, ConflictError, ValidationError
8
+ from validators import validate, required, is_email, min_len, max_len
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _now() -> str:
14
+ return datetime.now(timezone.utc).isoformat()
15
+
16
+
17
+ def create_user(name: str, email: str, role: str = "user") -> User:
18
+ errs = validate({"name": name, "email": email}, {
19
+ "name": [required, min_len(2), max_len(80)],
20
+ "email": [required, is_email],
21
+ })
22
+ if errs:
23
+ raise ValidationError("input", "; ".join(errs))
24
+ if db.fetch_one("SELECT id FROM users WHERE email = ?", (email,)):
25
+ raise ConflictError(f"Email already registered: {email}")
26
+ user = User(name=name, email=email, role=role)
27
+ db.execute(
28
+ "INSERT INTO users (id, name, email, role, active, created_at) VALUES (?,?,?,?,?,?)",
29
+ (user.id, user.name, user.email, user.role, int(user.active),
30
+ user.created_at.isoformat()),
31
+ )
32
+ logger.info("Created user %s (%s)", user.id, email)
33
+ return user
34
+
35
+
36
+ def get_user(user_id: str) -> User:
37
+ row = db.fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
38
+ if not row:
39
+ raise NotFoundError("User", user_id)
40
+ return User.from_dict(dict(row))
41
+
42
+
43
+ def list_users(page: int = 1, size: int = 20,
44
+ active_only: bool = True) -> dict[str, Any]:
45
+ sql = "SELECT * FROM users" + (" WHERE active = 1" if active_only else "")
46
+ sql += " ORDER BY created_at DESC"
47
+ return db.paginate(sql, page=page, size=size)
48
+
49
+
50
+ def deactivate_user(user_id: str) -> None:
51
+ if not db.execute("UPDATE users SET active = 0 WHERE id = ?", (user_id,)):
52
+ raise NotFoundError("User", user_id)
53
+ logger.info("Deactivated user %s", user_id)
54
+
55
+
56
+ def create_record(title: str, owner_id: str,
57
+ tags: Optional[list[str]] = None) -> Record:
58
+ errs = validate({"title": title}, {"title": [required, max_len(200)]})
59
+ if errs:
60
+ raise ValidationError("title", errs[0])
61
+ get_user(owner_id)
62
+ rec = Record(title=title, owner_id=owner_id, tags=tags or [])
63
+ db.execute(
64
+ "INSERT INTO records (id, title, owner_id, active, created_at) VALUES (?,?,?,?,?)",
65
+ (rec.id, rec.title, rec.owner_id, 1, rec.created_at.isoformat()),
66
+ )
67
+ if rec.tags:
68
+ db.execute_many(
69
+ "INSERT OR IGNORE INTO tags (record_id, tag) VALUES (?,?)",
70
+ [(rec.id, t) for t in rec.tags],
71
+ )
72
+ logger.info("Created record %s", rec.id)
73
+ return rec
74
+
75
+
76
+ def get_record(record_id: str) -> Record:
77
+ row = db.fetch_one("SELECT * FROM records WHERE id = ?", (record_id,))
78
+ if not row:
79
+ raise NotFoundError("Record", record_id)
80
+ tags = [r["tag"] for r in
81
+ db.fetch_all("SELECT tag FROM tags WHERE record_id = ?", (record_id,))]
82
+ return Record(**{k: row[k] for k in ("id","title","owner_id","active")}, tags=tags)
83
+
84
+
85
+ def delete_record(record_id: str) -> None:
86
+ if not db.execute("UPDATE records SET active = 0 WHERE id = ?", (record_id,)):
87
+ raise NotFoundError("Record", record_id)
88
+
89
+
90
+ def list_records(owner_id: Optional[str] = None,
91
+ page: int = 1, size: int = 20) -> dict[str, Any]:
92
+ sql = "SELECT * FROM records WHERE active = 1"
93
+ params: tuple = ()
94
+ if owner_id:
95
+ sql += " AND owner_id = ?"
96
+ params = (owner_id,)
97
+ return db.paginate(sql, params, page=page, size=size)
handlers/validators.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import Any, Callable
3
+
4
+ EMAIL_RE = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
5
+ PHONE_RE = re.compile(r'^\+?[\d\s\-().]{7,20}$')
6
+
7
+ Rule = Callable[[str, Any], str | None]
8
+
9
+
10
+ def required(field: str, value: Any) -> str | None:
11
+ if value is None or (isinstance(value, str) and not value.strip()):
12
+ return f"{field} is required"
13
+ return None
14
+
15
+
16
+ def min_len(n: int) -> Rule:
17
+ def check(field: str, value: Any) -> str | None:
18
+ if value is not None and len(str(value)) < n:
19
+ return f"{field} must be at least {n} characters"
20
+ return None
21
+ return check
22
+
23
+
24
+ def max_len(n: int) -> Rule:
25
+ def check(field: str, value: Any) -> str | None:
26
+ if value is not None and len(str(value)) > n:
27
+ return f"{field} must be at most {n} characters"
28
+ return None
29
+ return check
30
+
31
+
32
+ def is_email(field: str, value: Any) -> str | None:
33
+ if value and not EMAIL_RE.match(str(value)):
34
+ return f"{field} is not a valid email"
35
+ return None
36
+
37
+
38
+ def is_phone(field: str, value: Any) -> str | None:
39
+ if value and not PHONE_RE.match(str(value)):
40
+ return f"{field} is not a valid phone number"
41
+ return None
42
+
43
+
44
+ def is_positive(field: str, value: Any) -> str | None:
45
+ try:
46
+ if float(value) <= 0:
47
+ return f"{field} must be positive"
48
+ except (TypeError, ValueError):
49
+ return f"{field} must be a number"
50
+ return None
51
+
52
+
53
+ def one_of(*choices: Any) -> Rule:
54
+ def check(field: str, value: Any) -> str | None:
55
+ if value not in choices:
56
+ return f"{field} must be one of: {', '.join(map(str, choices))}"
57
+ return None
58
+ return check
59
+
60
+
61
+ def validate(data: dict[str, Any], rules: dict[str, list[Rule]]) -> list[str]:
62
+ errors: list[str] = []
63
+ for field, checks in rules.items():
64
+ for rule in checks:
65
+ err = rule(field, data.get(field))
66
+ if err:
67
+ errors.append(err)
68
+ return errors
middleware/app.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import time
3
+ from typing import Any, Optional
4
+
5
+ logger = logging.getLogger(__name__)
6
+ _MAX_RETRIES = 3
7
+
8
+
9
+ class App:
10
+ def __init__(self, debug: bool = False, dry_run: bool = False) -> None:
11
+ self.debug = debug
12
+ self.dry_run = dry_run
13
+ self._started = False
14
+ self._components: list[Any] = []
15
+ self._hooks: dict[str, list] = {"startup": [], "shutdown": []}
16
+
17
+ def on_startup(self, fn) -> None:
18
+ self._hooks["startup"].append(fn)
19
+
20
+ def on_shutdown(self, fn) -> None:
21
+ self._hooks["shutdown"].append(fn)
22
+
23
+ def _run_hooks(self, event: str) -> None:
24
+ for fn in self._hooks.get(event, []):
25
+ try:
26
+ fn()
27
+ except Exception as exc:
28
+ logger.error("Hook %s raised: %s", fn.__name__, exc)
29
+
30
+ def _load_components(self) -> None:
31
+ logger.debug("Loading components")
32
+ self._components = []
33
+
34
+ def _warmup(self) -> bool:
35
+ for attempt in range(1, _MAX_RETRIES + 1):
36
+ try:
37
+ self._load_components()
38
+ return True
39
+ except Exception as exc:
40
+ logger.warning("Warmup attempt %d/%d: %s", attempt, _MAX_RETRIES, exc)
41
+ time.sleep(0.5 * attempt)
42
+ return False
43
+
44
+ def run(self) -> None:
45
+ logger.info("Starting (debug=%s dry_run=%s)", self.debug, self.dry_run)
46
+ if not self._warmup():
47
+ logger.error("Warmup failed, aborting")
48
+ return
49
+ self._started = True
50
+ self._run_hooks("startup")
51
+ try:
52
+ self._main_loop()
53
+ finally:
54
+ self.shutdown()
55
+
56
+ def _main_loop(self) -> None:
57
+ if self.dry_run:
58
+ logger.info("[dry-run] skipping main loop")
59
+ return
60
+ logger.info("Running main loop")
61
+
62
+ def shutdown(self) -> None:
63
+ if not self._started:
64
+ return
65
+ self._run_hooks("shutdown")
66
+ self._components.clear()
67
+ self._started = False
68
+ logger.info("Shutdown complete")
69
+
70
+ def status(self) -> dict:
71
+ return {
72
+ "started": self._started,
73
+ "debug": self.debug,
74
+ "dry_run": self.dry_run,
75
+ "components": len(self._components),
76
+ }
middleware/auth.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import secrets
3
+ import time
4
+ from typing import Optional
5
+
6
+ from utils import hash_password, verify_password
7
+ from exceptions import AuthError
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _SESSION_TTL = 3600
12
+ _MAX_ATTEMPTS = 5
13
+ _LOCKOUT = 300
14
+
15
+ _sessions: dict[str, dict] = {}
16
+ _failures: dict[str, list[float]] = {}
17
+
18
+
19
+ def _prune() -> None:
20
+ now = time.time()
21
+ dead = [t for t, s in _sessions.items() if now - s["ts"] > _SESSION_TTL]
22
+ for t in dead:
23
+ del _sessions[t]
24
+
25
+
26
+ def _locked(username: str) -> bool:
27
+ cutoff = time.time() - _LOCKOUT
28
+ recent = [t for t in _failures.get(username, []) if t > cutoff]
29
+ _failures[username] = recent
30
+ return len(recent) >= _MAX_ATTEMPTS
31
+
32
+
33
+ def login(username: str, password: str,
34
+ stored_digest: str, stored_salt: str) -> str:
35
+ if _locked(username):
36
+ raise AuthError("Account temporarily locked")
37
+ if not verify_password(password, stored_digest, stored_salt):
38
+ _failures.setdefault(username, []).append(time.time())
39
+ raise AuthError("Invalid credentials")
40
+ _prune()
41
+ token = secrets.token_hex(32)
42
+ _sessions[token] = {"username": username, "ts": time.time()}
43
+ logger.info("Login: %s", username)
44
+ return token
45
+
46
+
47
+ def logout(token: str) -> None:
48
+ s = _sessions.pop(token, None)
49
+ if s:
50
+ logger.info("Logout: %s", s["username"])
51
+
52
+
53
+ def whoami(token: str) -> Optional[str]:
54
+ s = _sessions.get(token)
55
+ if not s:
56
+ return None
57
+ if time.time() - s["ts"] > _SESSION_TTL:
58
+ del _sessions[token]
59
+ return None
60
+ return s["username"]
61
+
62
+
63
+ def require_auth(token: str) -> str:
64
+ user = whoami(token)
65
+ if user is None:
66
+ raise AuthError()
67
+ return user
68
+
69
+
70
+ def refresh(token: str) -> str:
71
+ user = require_auth(token)
72
+ logout(token)
73
+ new = secrets.token_hex(32)
74
+ _sessions[new] = {"username": user, "ts": time.time()}
75
+ return new
76
+
77
+
78
+ def session_count() -> int:
79
+ _prune()
80
+ return len(_sessions)
middleware/cli.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import sys
5
+ from typing import Any, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def _json(data: Any) -> None:
11
+ print(json.dumps(data, indent=2, default=str))
12
+
13
+
14
+ def _table(rows: list[dict], cols: Optional[list[str]] = None) -> None:
15
+ if not rows:
16
+ print("(empty)")
17
+ return
18
+ cols = cols or list(rows[0].keys())
19
+ widths = {c: max(len(c), max(len(str(r.get(c, ""))) for r in rows))
20
+ for c in cols}
21
+ sep = " "
22
+ print(sep.join(c.ljust(widths[c]) for c in cols))
23
+ print("-" * (sum(widths.values()) + len(sep) * (len(cols) - 1)))
24
+ for row in rows:
25
+ print(sep.join(str(row.get(c, "")).ljust(widths[c]) for c in cols))
26
+
27
+
28
+ def cmd_list(args: argparse.Namespace) -> int:
29
+ logger.debug("list: page=%d size=%d", args.page, args.size)
30
+ print(f"Listing (page={args.page} size={args.size})")
31
+ return 0
32
+
33
+
34
+ def cmd_add(args: argparse.Namespace) -> int:
35
+ logger.debug("add: name=%s tags=%s", args.name, args.tags)
36
+ print(f"Added: {args.name!r}")
37
+ return 0
38
+
39
+
40
+ def cmd_delete(args: argparse.Namespace) -> int:
41
+ if not args.yes:
42
+ confirm = input(f"Delete {args.id}? [y/N] ")
43
+ if confirm.lower() != "y":
44
+ print("Aborted.")
45
+ return 0
46
+ print(f"Deleted: {args.id}")
47
+ return 0
48
+
49
+
50
+ def cmd_show(args: argparse.Namespace) -> int:
51
+ print(f"Record: {args.id}")
52
+ return 0
53
+
54
+
55
+ def cmd_export(args: argparse.Namespace) -> int:
56
+ print(f"Exported as {args.format} to {args.output}")
57
+ return 0
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ p = argparse.ArgumentParser(prog="app",
62
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
63
+ p.add_argument("--json", dest="as_json", action="store_true")
64
+ sub = p.add_subparsers(dest="cmd", required=True)
65
+
66
+ ls = sub.add_parser("list")
67
+ ls.add_argument("--page", type=int, default=1)
68
+ ls.add_argument("--size", type=int, default=20)
69
+ ls.set_defaults(func=cmd_list)
70
+
71
+ add = sub.add_parser("add")
72
+ add.add_argument("name")
73
+ add.add_argument("--tag", action="append", dest="tags", default=[])
74
+ add.set_defaults(func=cmd_add)
75
+
76
+ rm = sub.add_parser("delete")
77
+ rm.add_argument("id")
78
+ rm.add_argument("-y", "--yes", action="store_true")
79
+ rm.set_defaults(func=cmd_delete)
80
+
81
+ show = sub.add_parser("show")
82
+ show.add_argument("id")
83
+ show.set_defaults(func=cmd_show)
84
+
85
+ exp = sub.add_parser("export")
86
+ exp.add_argument("--format", choices=["csv", "json", "text"], default="text")
87
+ exp.add_argument("--output", "-o", default="-")
88
+ exp.set_defaults(func=cmd_export)
89
+
90
+ return p
91
+
92
+
93
+ def run_cli() -> int:
94
+ args = build_parser().parse_args()
95
+ try:
96
+ return args.func(args)
97
+ except Exception as exc:
98
+ logger.error("%s", exc)
99
+ return 1
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(run_cli())
middleware/service.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime, timezone
3
+ from typing import Any, Optional
4
+
5
+ from models import Record, User
6
+ import database as db
7
+ from exceptions import NotFoundError, ConflictError, ValidationError
8
+ from validators import validate, required, is_email, min_len, max_len
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _now() -> str:
14
+ return datetime.now(timezone.utc).isoformat()
15
+
16
+
17
+ def create_user(name: str, email: str, role: str = "user") -> User:
18
+ errs = validate({"name": name, "email": email}, {
19
+ "name": [required, min_len(2), max_len(80)],
20
+ "email": [required, is_email],
21
+ })
22
+ if errs:
23
+ raise ValidationError("input", "; ".join(errs))
24
+ if db.fetch_one("SELECT id FROM users WHERE email = ?", (email,)):
25
+ raise ConflictError(f"Email already registered: {email}")
26
+ user = User(name=name, email=email, role=role)
27
+ db.execute(
28
+ "INSERT INTO users (id, name, email, role, active, created_at) VALUES (?,?,?,?,?,?)",
29
+ (user.id, user.name, user.email, user.role, int(user.active),
30
+ user.created_at.isoformat()),
31
+ )
32
+ logger.info("Created user %s (%s)", user.id, email)
33
+ return user
34
+
35
+
36
+ def get_user(user_id: str) -> User:
37
+ row = db.fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
38
+ if not row:
39
+ raise NotFoundError("User", user_id)
40
+ return User.from_dict(dict(row))
41
+
42
+
43
+ def list_users(page: int = 1, size: int = 20,
44
+ active_only: bool = True) -> dict[str, Any]:
45
+ sql = "SELECT * FROM users" + (" WHERE active = 1" if active_only else "")
46
+ sql += " ORDER BY created_at DESC"
47
+ return db.paginate(sql, page=page, size=size)
48
+
49
+
50
+ def deactivate_user(user_id: str) -> None:
51
+ if not db.execute("UPDATE users SET active = 0 WHERE id = ?", (user_id,)):
52
+ raise NotFoundError("User", user_id)
53
+ logger.info("Deactivated user %s", user_id)
54
+
55
+
56
+ def create_record(title: str, owner_id: str,
57
+ tags: Optional[list[str]] = None) -> Record:
58
+ errs = validate({"title": title}, {"title": [required, max_len(200)]})
59
+ if errs:
60
+ raise ValidationError("title", errs[0])
61
+ get_user(owner_id)
62
+ rec = Record(title=title, owner_id=owner_id, tags=tags or [])
63
+ db.execute(
64
+ "INSERT INTO records (id, title, owner_id, active, created_at) VALUES (?,?,?,?,?)",
65
+ (rec.id, rec.title, rec.owner_id, 1, rec.created_at.isoformat()),
66
+ )
67
+ if rec.tags:
68
+ db.execute_many(
69
+ "INSERT OR IGNORE INTO tags (record_id, tag) VALUES (?,?)",
70
+ [(rec.id, t) for t in rec.tags],
71
+ )
72
+ logger.info("Created record %s", rec.id)
73
+ return rec
74
+
75
+
76
+ def get_record(record_id: str) -> Record:
77
+ row = db.fetch_one("SELECT * FROM records WHERE id = ?", (record_id,))
78
+ if not row:
79
+ raise NotFoundError("Record", record_id)
80
+ tags = [r["tag"] for r in
81
+ db.fetch_all("SELECT tag FROM tags WHERE record_id = ?", (record_id,))]
82
+ return Record(**{k: row[k] for k in ("id","title","owner_id","active")}, tags=tags)
83
+
84
+
85
+ def delete_record(record_id: str) -> None:
86
+ if not db.execute("UPDATE records SET active = 0 WHERE id = ?", (record_id,)):
87
+ raise NotFoundError("Record", record_id)
88
+
89
+
90
+ def list_records(owner_id: Optional[str] = None,
91
+ page: int = 1, size: int = 20) -> dict[str, Any]:
92
+ sql = "SELECT * FROM records WHERE active = 1"
93
+ params: tuple = ()
94
+ if owner_id:
95
+ sql += " AND owner_id = ?"
96
+ params = (owner_id,)
97
+ return db.paginate(sql, params, page=page, size=size)
models.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import Optional
5
+ import uuid
6
+
7
+
8
+ def _uid() -> str:
9
+ return str(uuid.uuid4())
10
+
11
+ def _now() -> datetime:
12
+ return datetime.utcnow()
13
+
14
+
15
+ @dataclass
16
+ class User:
17
+ name: str
18
+ email: str
19
+ id: str = field(default_factory=_uid)
20
+ role: str = "user"
21
+ active: bool = True
22
+ created_at: datetime = field(default_factory=_now)
23
+
24
+ def deactivate(self) -> None:
25
+ self.active = False
26
+
27
+ def promote(self, role: str = "admin") -> None:
28
+ self.role = role
29
+
30
+ def to_dict(self) -> dict:
31
+ return {
32
+ "id": self.id, "name": self.name, "email": self.email,
33
+ "role": self.role, "active": self.active,
34
+ "created_at": self.created_at.isoformat(),
35
+ }
36
+
37
+ @classmethod
38
+ def from_dict(cls, d: dict) -> User:
39
+ return cls(
40
+ id=d.get("id", _uid()), name=d["name"], email=d["email"],
41
+ role=d.get("role", "user"), active=d.get("active", True),
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class Record:
47
+ title: str
48
+ owner_id: str
49
+ id: str = field(default_factory=_uid)
50
+ tags: list[str] = field(default_factory=list)
51
+ active: bool = True
52
+ created_at: datetime = field(default_factory=_now)
53
+ updated_at: Optional[datetime] = None
54
+
55
+ def touch(self) -> None:
56
+ self.updated_at = _now()
57
+
58
+ def add_tag(self, tag: str) -> None:
59
+ if tag and tag not in self.tags:
60
+ self.tags.append(tag)
61
+
62
+ def remove_tag(self, tag: str) -> None:
63
+ self.tags = [t for t in self.tags if t != tag]
64
+
65
+ def update(self, **kw) -> None:
66
+ for k, v in kw.items():
67
+ if hasattr(self, k):
68
+ setattr(self, k, v)
69
+ self.touch()
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "id": self.id, "title": self.title, "owner_id": self.owner_id,
74
+ "tags": self.tags, "active": self.active,
75
+ "created_at": self.created_at.isoformat(),
76
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
77
+ }
password ADDED
@@ -0,0 +1 @@
 
 
1
+ YOavpdFu15tmT
utils.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import hmac
3
+ import re
4
+ import secrets
5
+ import string
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Optional
8
+
9
+ EMAIL_RE = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
10
+
11
+
12
+ def hash_password(pw: str, salt: Optional[str] = None) -> tuple[str, str]:
13
+ if salt is None:
14
+ salt = secrets.token_hex(16)
15
+ digest = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000)
16
+ return digest.hex(), salt
17
+
18
+
19
+ def verify_password(pw: str, digest: str, salt: str) -> bool:
20
+ computed, _ = hash_password(pw, salt)
21
+ return hmac.compare_digest(computed, digest)
22
+
23
+
24
+ def generate_token(n: int = 32) -> str:
25
+ return "".join(secrets.choice(string.ascii_letters + string.digits)
26
+ for _ in range(n))
27
+
28
+
29
+ def slugify(text: str, max_len: int = 60) -> str:
30
+ text = text.lower().replace(" ", "-")
31
+ text = re.sub(r"[^\w-]", "", text)
32
+ return text[:max_len].strip("-")
33
+
34
+
35
+ def truncate(text: str, n: int = 120) -> str:
36
+ return text if len(text) <= n else text[:n - 3] + "..."
37
+
38
+
39
+ def utcnow() -> str:
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ def parse_bool(v: Any) -> bool:
44
+ if isinstance(v, bool):
45
+ return v
46
+ return str(v).strip().lower() in ("1", "true", "yes", "on")
47
+
48
+
49
+ def chunk(lst: list, size: int) -> list[list]:
50
+ return [lst[i:i + size] for i in range(0, len(lst), size)]
51
+
52
+
53
+ def flatten(nested: list) -> list:
54
+ result = []
55
+ for item in nested:
56
+ if isinstance(item, list):
57
+ result.extend(flatten(item))
58
+ else:
59
+ result.append(item)
60
+ return result
61
+
62
+
63
+ def deep_merge(base: dict, override: dict) -> dict:
64
+ out = dict(base)
65
+ for k, v in override.items():
66
+ if k in out and isinstance(out[k], dict) and isinstance(v, dict):
67
+ out[k] = deep_merge(out[k], v)
68
+ else:
69
+ out[k] = v
70
+ return out
validators.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import Any, Callable
3
+
4
+ EMAIL_RE = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
5
+ PHONE_RE = re.compile(r'^\+?[\d\s\-().]{7,20}$')
6
+
7
+ Rule = Callable[[str, Any], str | None]
8
+
9
+
10
+ def required(field: str, value: Any) -> str | None:
11
+ if value is None or (isinstance(value, str) and not value.strip()):
12
+ return f"{field} is required"
13
+ return None
14
+
15
+
16
+ def min_len(n: int) -> Rule:
17
+ def check(field: str, value: Any) -> str | None:
18
+ if value is not None and len(str(value)) < n:
19
+ return f"{field} must be at least {n} characters"
20
+ return None
21
+ return check
22
+
23
+
24
+ def max_len(n: int) -> Rule:
25
+ def check(field: str, value: Any) -> str | None:
26
+ if value is not None and len(str(value)) > n:
27
+ return f"{field} must be at most {n} characters"
28
+ return None
29
+ return check
30
+
31
+
32
+ def is_email(field: str, value: Any) -> str | None:
33
+ if value and not EMAIL_RE.match(str(value)):
34
+ return f"{field} is not a valid email"
35
+ return None
36
+
37
+
38
+ def is_phone(field: str, value: Any) -> str | None:
39
+ if value and not PHONE_RE.match(str(value)):
40
+ return f"{field} is not a valid phone number"
41
+ return None
42
+
43
+
44
+ def is_positive(field: str, value: Any) -> str | None:
45
+ try:
46
+ if float(value) <= 0:
47
+ return f"{field} must be positive"
48
+ except (TypeError, ValueError):
49
+ return f"{field} must be a number"
50
+ return None
51
+
52
+
53
+ def one_of(*choices: Any) -> Rule:
54
+ def check(field: str, value: Any) -> str | None:
55
+ if value not in choices:
56
+ return f"{field} must be one of: {', '.join(map(str, choices))}"
57
+ return None
58
+ return check
59
+
60
+
61
+ def validate(data: dict[str, Any], rules: dict[str, list[Rule]]) -> list[str]:
62
+ errors: list[str] = []
63
+ for field, checks in rules.items():
64
+ for rule in checks:
65
+ err = rule(field, data.get(field))
66
+ if err:
67
+ errors.append(err)
68
+ return errors