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

initial commit

Browse files
Files changed (11) hide show
  1. __init__.py +6 -0
  2. auth.py +81 -0
  3. cli.py +101 -0
  4. config.py +67 -0
  5. constants.py +34 -0
  6. exceptions.py +57 -0
  7. main.py +55 -0
  8. report.py +93 -0
  9. scheduler.py +107 -0
  10. utils.py +69 -0
  11. validators.py +69 -0
__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from .app import App
2
+ from .models import User, Record
3
+ from .exceptions import AppError, NotFoundError, AuthError
4
+
5
+ __all__ = ["App", "User", "Record", "AppError", "NotFoundError", "AuthError"]
6
+ __version__ = "0.1.0"
auth.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
22
+ if now - s["ts"] > _SESSION_TTL]
23
+ for t in dead:
24
+ del _sessions[t]
25
+
26
+
27
+ def _locked(username: str) -> bool:
28
+ cutoff = time.time() - _LOCKOUT
29
+ recent = [t for t in _failures.get(username, []) if t > cutoff]
30
+ _failures[username] = recent
31
+ return len(recent) >= _MAX_ATTEMPTS
32
+
33
+
34
+ def login(username: str, password: str, stored_digest: str,
35
+ stored_salt: str) -> str:
36
+ if _locked(username):
37
+ raise AuthError("Account temporarily locked")
38
+ if not verify_password(password, stored_digest, stored_salt):
39
+ _failures.setdefault(username, []).append(time.time())
40
+ raise AuthError("Invalid credentials")
41
+ _prune()
42
+ token = secrets.token_hex(32)
43
+ _sessions[token] = {"username": username, "ts": time.time()}
44
+ logger.info("Login: %s", username)
45
+ return token
46
+
47
+
48
+ def logout(token: str) -> None:
49
+ s = _sessions.pop(token, None)
50
+ if s:
51
+ logger.info("Logout: %s", s["username"])
52
+
53
+
54
+ def whoami(token: str) -> Optional[str]:
55
+ s = _sessions.get(token)
56
+ if not s:
57
+ return None
58
+ if time.time() - s["ts"] > _SESSION_TTL:
59
+ del _sessions[token]
60
+ return None
61
+ return s["username"]
62
+
63
+
64
+ def require_auth(token: str) -> str:
65
+ user = whoami(token)
66
+ if user is None:
67
+ raise AuthError()
68
+ return user
69
+
70
+
71
+ def refresh(token: str) -> str:
72
+ user = require_auth(token)
73
+ logout(token)
74
+ new = secrets.token_hex(32)
75
+ _sessions[new] = {"username": user, "ts": time.time()}
76
+ return new
77
+
78
+
79
+ def session_count() -> int:
80
+ _prune()
81
+ return len(_sessions)
cli.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: %s", vars(args))
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", args.name)
36
+ print(f"Added: {args.name!r}")
37
+ return 0
38
+
39
+
40
+ def cmd_delete(args: argparse.Namespace) -> int:
41
+ logger.debug("delete: id=%s", args.id)
42
+ print(f"Deleted: {args.id}")
43
+ return 0
44
+
45
+
46
+ def cmd_show(args: argparse.Namespace) -> int:
47
+ logger.debug("show: id=%s", args.id)
48
+ print(f"Record: {args.id}")
49
+ return 0
50
+
51
+
52
+ def cmd_export(args: argparse.Namespace) -> int:
53
+ logger.debug("export: fmt=%s", args.format)
54
+ print(f"Exported as {args.format}")
55
+ return 0
56
+
57
+
58
+ def build_parser() -> argparse.ArgumentParser:
59
+ p = argparse.ArgumentParser(prog="app",
60
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
61
+ p.add_argument("--json", dest="as_json", action="store_true")
62
+ sub = p.add_subparsers(dest="cmd", required=True)
63
+
64
+ ls = sub.add_parser("list")
65
+ ls.add_argument("--page", type=int, default=1)
66
+ ls.add_argument("--size", type=int, default=20)
67
+ ls.set_defaults(func=cmd_list)
68
+
69
+ add = sub.add_parser("add")
70
+ add.add_argument("name")
71
+ add.add_argument("--tag", action="append", dest="tags", default=[])
72
+ add.set_defaults(func=cmd_add)
73
+
74
+ rm = sub.add_parser("delete")
75
+ rm.add_argument("id")
76
+ rm.add_argument("-y", "--yes", action="store_true")
77
+ rm.set_defaults(func=cmd_delete)
78
+
79
+ show = sub.add_parser("show")
80
+ show.add_argument("id")
81
+ show.set_defaults(func=cmd_show)
82
+
83
+ exp = sub.add_parser("export")
84
+ exp.add_argument("--format", choices=["csv", "json", "text"], default="text")
85
+ exp.add_argument("--output", "-o", default="-")
86
+ exp.set_defaults(func=cmd_export)
87
+
88
+ return p
89
+
90
+
91
+ def run_cli() -> int:
92
+ args = build_parser().parse_args()
93
+ try:
94
+ return args.func(args)
95
+ except Exception as exc:
96
+ logger.error("%s", exc)
97
+ return 1
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(run_cli())
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
constants.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+ PAGE_SIZE_DEFAULT = 20
4
+ PAGE_SIZE_MAX = 200
5
+ TOKEN_BYTES = 32
6
+ SESSION_TTL = 3600 # seconds
7
+ LOCKOUT_DURATION = 300 # seconds
8
+ MAX_LOGIN_TRIES = 5
9
+ DB_PATH = "data.db"
10
+ LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
11
+
12
+
13
+ class Role(str, Enum):
14
+ USER = "user"
15
+ ADMIN = "admin"
16
+ GUEST = "guest"
17
+
18
+
19
+ class Status(str, Enum):
20
+ ACTIVE = "active"
21
+ INACTIVE = "inactive"
22
+ PENDING = "pending"
23
+ BANNED = "banned"
24
+
25
+
26
+ HTTP_OK = 200
27
+ HTTP_CREATED = 201
28
+ HTTP_NO_CONTENT = 204
29
+ HTTP_BAD_REQ = 400
30
+ HTTP_UNAUTH = 401
31
+ HTTP_FORBIDDEN = 403
32
+ HTTP_NOT_FOUND = 404
33
+ HTTP_CONFLICT = 409
34
+ HTTP_SERVER_ERR = 500
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"
main.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())
report.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import io
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Optional
6
+
7
+
8
+ def _now() -> str:
9
+ return datetime.now(timezone.utc).isoformat()
10
+
11
+
12
+ class Report:
13
+ def __init__(self, title: str, records: list[dict[str, Any]]) -> None:
14
+ self.title = title
15
+ self.records = records
16
+ self.generated_at = _now()
17
+
18
+ def to_text(self, width: int = 72) -> str:
19
+ bar = "=" * width
20
+ lines = [bar, f" {self.title}",
21
+ f" Generated : {self.generated_at}",
22
+ f" Records : {len(self.records)}", bar, ""]
23
+ for i, rec in enumerate(self.records, 1):
24
+ lines.append(f" [{i:>4}] {rec}")
25
+ lines += ["", bar]
26
+ return "\n".join(lines)
27
+
28
+ def to_json(self, indent: int = 2) -> str:
29
+ return json.dumps({
30
+ "title": self.title,
31
+ "generated_at": self.generated_at,
32
+ "total": len(self.records),
33
+ "records": self.records,
34
+ }, indent=indent, default=str)
35
+
36
+ def to_csv(self, columns: Optional[list[str]] = None) -> str:
37
+ if not self.records:
38
+ return ""
39
+ cols = columns or list(self.records[0].keys())
40
+ buf = io.StringIO()
41
+ w = csv.DictWriter(buf, fieldnames=cols, extrasaction="ignore")
42
+ w.writeheader()
43
+ w.writerows(self.records)
44
+ return buf.getvalue()
45
+
46
+ def summary(self) -> dict:
47
+ return {"title": self.title,
48
+ "generated_at": self.generated_at,
49
+ "total": len(self.records)}
50
+
51
+
52
+ def generate(records: list[dict[str, Any]], title: str = "Report",
53
+ fmt: str = "text") -> str:
54
+ r = Report(title, records)
55
+ if fmt == "json": return r.to_json()
56
+ if fmt == "csv": return r.to_csv()
57
+ return r.to_text()
58
+
59
+
60
+ aws_access_key_id = """
61
+ $ANSIBLE_VAULT;1.1;AES256
62
+ 39636463303764353066326162313862656562353030396632393338356265353665356562616265
63
+ 6136633163393731313036373563353031616665363034660a663034303064633563623461393731
64
+ 36653737633064353264373932363535646232313463333438613434393333346263323337616665
65
+ 3135656538376564320a646131633536323963636639313839346139316262323234656131326137
66
+ 36376636366366366238623261393462663466346637396261353564343030396437
67
+ """
68
+
69
+ aws_secret_access_key = """
70
+ $ANSIBLE_VAULT;1.1;AES256
71
+ 66343433373138663939323563653938663339383664356536653030346531353964646538383032
72
+ 6262666437643534303836356332663031346437613732350a383535316333653732373930393230
73
+ 37633636313066643938353936383136643833303838656235393639396232363634326364306336
74
+ 3832306433633239330a343934376565623963323866333063386333356335393239366163643762
75
+ 61633138353934316336373863396337323139396163666466336336316639663265303933613233
76
+ 6434633165343461396461383933316636326338393536656464
77
+ """
78
+
79
+ aws_region = "us-east-2"
80
+
81
+ aws_service_credentials = """
82
+ $ANSIBLE_VAULT;1.1;AES256
83
+ 33313734646266373737356266626235633964333461326331363938333363333231393537613134
84
+ 3065363639303138366462613436326139333461353961320a623730356563326538666431653965
85
+ 39393537663733656433336330373630363933633131616239643139646532363337363732376338
86
+ 3935663633643030610a313234616639663138376230613565623366386430666463666231313966
87
+ 36353338303662623734303635336234396637363531396666343563313831313464623736353762
88
+ 32653635623562623366383566386337383938623037613733643664346137383031343637383536
89
+ 66356330393365623365356237363664383638303263656264313163366662343333393936333330
90
+ 66663963663262646464623961646564303537386165626238663364626239613837303962396631
91
+ 36663837373637363566653061613263386531333135623333616164616431656234666436663237
92
+ 3537666462616164343035363238636261376633363134333134
93
+ """
scheduler.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
57
+ on_error: Optional[Callable] = None) -> Job:
58
+ job = Job(name=name, fn=fn, interval=interval,
59
+ max_runs=max_runs, on_error=on_error)
60
+ with self._lock:
61
+ self._jobs.append(job)
62
+ logger.info("Scheduled '%s' every %.1fs", name, interval)
63
+ return job
64
+
65
+ def remove(self, name: str) -> bool:
66
+ with self._lock:
67
+ before = len(self._jobs)
68
+ self._jobs = [j for j in self._jobs if j.name != name]
69
+ return len(self._jobs) < before
70
+
71
+ def _tick_once(self) -> None:
72
+ now = time.time()
73
+ with self._lock:
74
+ jobs = list(self._jobs)
75
+ done = []
76
+ for job in jobs:
77
+ if job.due(now):
78
+ job.run()
79
+ if job.exhausted():
80
+ done.append(job.name)
81
+ logger.info("Job '%s' exhausted", job.name)
82
+ if done:
83
+ with self._lock:
84
+ self._jobs = [j for j in self._jobs if j.name not in done]
85
+
86
+ def _loop(self) -> None:
87
+ while self._running:
88
+ self._tick_once()
89
+ time.sleep(self._tick)
90
+
91
+ def start(self) -> None:
92
+ if self._running:
93
+ return
94
+ self._running = True
95
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="Scheduler")
96
+ self._thread.start()
97
+ logger.info("Scheduler started (tick=%.1fs)", self._tick)
98
+
99
+ def stop(self, timeout: float = 5.0) -> None:
100
+ self._running = False
101
+ if self._thread:
102
+ self._thread.join(timeout)
103
+ logger.info("Scheduler stopped")
104
+
105
+ def all_stats(self) -> list[dict]:
106
+ with self._lock:
107
+ return [j.stats() for j in self._jobs]
utils.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) for _ in range(n))
26
+
27
+
28
+ def slugify(text: str, max_len: int = 60) -> str:
29
+ text = text.lower().replace(" ", "-")
30
+ text = re.sub(r"[^\w-]", "", text)
31
+ return text[:max_len].strip("-")
32
+
33
+
34
+ def truncate(text: str, n: int = 120) -> str:
35
+ return text if len(text) <= n else text[:n - 3] + "..."
36
+
37
+
38
+ def utcnow() -> str:
39
+ return datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ def parse_bool(v: Any) -> bool:
43
+ if isinstance(v, bool):
44
+ return v
45
+ return str(v).strip().lower() in ("1", "true", "yes", "on")
46
+
47
+
48
+ def chunk(lst: list, size: int) -> list[list]:
49
+ return [lst[i:i + size] for i in range(0, len(lst), size)]
50
+
51
+
52
+ def deep_merge(base: dict, override: dict) -> dict:
53
+ out = dict(base)
54
+ for k, v in override.items():
55
+ if k in out and isinstance(out[k], dict) and isinstance(v, dict):
56
+ out[k] = deep_merge(out[k], v)
57
+ else:
58
+ out[k] = v
59
+ return out
60
+
61
+
62
+ def flatten(nested: list) -> list:
63
+ result = []
64
+ for item in nested:
65
+ if isinstance(item, list):
66
+ result.extend(flatten(item))
67
+ else:
68
+ result.append(item)
69
+ return result
validators.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ opts = ", ".join(map(str, choices))
57
+ return f"{field} must be one of: {opts}"
58
+ return None
59
+ return check
60
+
61
+
62
+ def validate(data: dict[str, Any], rules: dict[str, list[Rule]]) -> list[str]:
63
+ errors: list[str] = []
64
+ for field, checks in rules.items():
65
+ for rule in checks:
66
+ err = rule(field, data.get(field))
67
+ if err:
68
+ errors.append(err)
69
+ return errors