flova 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flova/__init__.py ADDED
File without changes
flova/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from flova.entrypoints.cli import main
2
+
3
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,46 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import logging
5
+ import threading
6
+
7
+ from flova.config import Webhook
8
+ from flova.core.ports import Repository, SyncHook
9
+
10
+
11
+ class DeploymentService:
12
+ def __init__(self, config: Webhook, repository: Repository, hook: SyncHook, lock=None):
13
+ self.config, self.repository, self.hook = config, repository, hook
14
+ self.lock = lock if lock is not None else threading.Lock()
15
+
16
+ def handle(self, payload: bytes, signature: str, event: str):
17
+ expected = (
18
+ "sha256=" + hmac.new(self.config.secret.encode(), payload, hashlib.sha256).hexdigest()
19
+ )
20
+ if not signature or not hmac.compare_digest(expected, signature):
21
+ return 401, "Invalid signature"
22
+ if event != "push":
23
+ return 200, "Ignored non-push event"
24
+ try:
25
+ data = json.loads(payload)
26
+ except (ValueError, UnicodeDecodeError):
27
+ return 400, "Invalid JSON"
28
+ if not isinstance(data, dict):
29
+ return 400, "Invalid JSON"
30
+ if data.get("ref") != "refs/heads/" + self.config.branch:
31
+ return 200, "Ignored branch"
32
+ if not self.lock.acquire(blocking=False):
33
+ return 409, "Deployment in progress"
34
+ try:
35
+ if not self.repository.sync(self.config.repository, self.config.branch):
36
+ return 500, "Deploy failed"
37
+ try:
38
+ self.hook.trigger()
39
+ except Exception as error:
40
+ logging.warning("Post-sync hook failed: %s", type(error).__name__)
41
+ return 200, "Deploy success"
42
+ except Exception as error:
43
+ logging.error("Deployment failed: %s", type(error).__name__)
44
+ return 500, "Deploy failed"
45
+ finally:
46
+ self.lock.release()
@@ -0,0 +1,206 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from flova.core.errors import StageError
10
+ from flova.core.ports import CommandRunner
11
+
12
+
13
+ def as_json(text, stage):
14
+ try:
15
+ return json.loads(text)
16
+ except (ValueError, TypeError):
17
+ raise StageError(stage, "invalid-json") from None
18
+
19
+
20
+ def prepare(args, run):
21
+ day = args.date or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
22
+ datetime.strptime(day, "%Y-%m-%d")
23
+ config = json.loads((args.flow / "config/download.json").read_text())
24
+ errors = []
25
+ ready = 0
26
+ for provider, settings in config.items():
27
+ stage = "download"
28
+ try:
29
+ if provider not in {"alipay", "wechat"}:
30
+ raise StageError("download", "unsupported-provider")
31
+ if not args.offline:
32
+ result = as_json(
33
+ run([args.dflow, "download", "-p", provider], stage, args.flow, timeout=300),
34
+ stage,
35
+ )
36
+ if result.get("status") != "success":
37
+ raise StageError(stage, result.get("status", "unknown"))
38
+ stage = "unzip"
39
+ target = Path(settings["target"])
40
+ output = target / f"{day}.{settings['suffix']}"
41
+ # A stale output must not make a failed unzip look successful.
42
+ import tempfile
43
+
44
+ with tempfile.TemporaryDirectory(prefix="n8n-unzip-", dir=target) as directory:
45
+ run(
46
+ [
47
+ args.oub,
48
+ "unzip",
49
+ "--path",
50
+ str(target / f"{day}.zip"),
51
+ "--output",
52
+ directory,
53
+ "--name",
54
+ output.name,
55
+ ],
56
+ stage,
57
+ args.flow,
58
+ )
59
+ extracted = Path(directory) / output.name
60
+ if not extracted.is_file() or extracted.stat().st_size == 0:
61
+ raise StageError(stage, "missing-output")
62
+ os.replace(extracted, output)
63
+ ready += 1
64
+ except (StageError, OSError, subprocess.TimeoutExpired) as error:
65
+ errors.append(
66
+ {
67
+ "ok": False,
68
+ "stage": stage,
69
+ "provider": provider,
70
+ "message": str(error)
71
+ if isinstance(error, StageError)
72
+ else f"{stage} failed; check server configuration",
73
+ }
74
+ )
75
+ if not ready:
76
+ return errors
77
+ try:
78
+ flags = [] if args.offline else ["--write"]
79
+ report = as_json(
80
+ run(
81
+ [
82
+ args.fa,
83
+ "--config",
84
+ str(args.config),
85
+ "bill",
86
+ "sync",
87
+ "daily",
88
+ "--date",
89
+ day,
90
+ "--json",
91
+ *flags,
92
+ ],
93
+ "import",
94
+ args.flow,
95
+ ),
96
+ "import",
97
+ )
98
+ if not args.offline:
99
+ # Preserve old markers so restoring the old workflow cannot immediately
100
+ # append the same successfully imported files again.
101
+ for source in report.get("sources", []):
102
+ if source.get("status") not in {"changed", "unchanged"} or not source.get("path"):
103
+ continue
104
+ path = Path(source["path"])
105
+ raw = path.read_bytes()
106
+ if hashlib.sha256(raw).hexdigest() != source.get("sha256"):
107
+ raise StageError("import", "source-changed-after-sync")
108
+ marker = Path(str(path) + ".md5")
109
+ temporary = Path(str(marker) + ".n8n-tmp")
110
+ temporary.write_text(hashlib.md5(raw).hexdigest() + "\n")
111
+ os.replace(temporary, marker)
112
+ payload = as_json(
113
+ run(
114
+ [
115
+ args.fa,
116
+ "--config",
117
+ str(args.config),
118
+ "classify",
119
+ "extract",
120
+ "--root",
121
+ str(args.ledger.parent),
122
+ ],
123
+ "extract",
124
+ args.flow,
125
+ ),
126
+ "extract",
127
+ )
128
+ if payload.get("schema_version") != 1 or not isinstance(payload.get("transactions"), list):
129
+ raise StageError("extract", "invalid-payload")
130
+ return errors + [
131
+ {
132
+ "ok": True,
133
+ "payload": payload,
134
+ "import": report,
135
+ "has_fixme": bool(payload["transactions"]),
136
+ }
137
+ ]
138
+ except StageError as error:
139
+ return errors + [{"ok": False, "stage": error.stage, "message": str(error)}]
140
+
141
+
142
+ def finish(args, run):
143
+ results = {}
144
+ flags = ["--write"] if args.write else []
145
+ if args.decisions_base64:
146
+ results["classification"] = as_json(
147
+ run(
148
+ [
149
+ args.fa,
150
+ "--config",
151
+ str(args.config),
152
+ "classify",
153
+ "apply",
154
+ "--root",
155
+ str(args.ledger.parent),
156
+ "--input-base64",
157
+ args.decisions_base64,
158
+ "--validator",
159
+ "docker exec fava bean-check /bean/main.bean",
160
+ *flags,
161
+ ],
162
+ "classify",
163
+ args.flow,
164
+ ),
165
+ "classify",
166
+ )
167
+ if args.subscriptions:
168
+ results["subscriptions"] = as_json(
169
+ run(
170
+ [
171
+ args.fa,
172
+ "--config",
173
+ str(args.config),
174
+ "subscriptions",
175
+ "generate",
176
+ "--ledger",
177
+ str(args.ledger),
178
+ "--subscriptions",
179
+ str(args.subscriptions),
180
+ "--json",
181
+ *flags,
182
+ ],
183
+ "subscriptions",
184
+ args.flow,
185
+ ),
186
+ "subscriptions",
187
+ )
188
+ elif args.write:
189
+ # Keep the existing subscription operation until its actual plan is known.
190
+ # A missing Makefile target is an error, never an implicit empty plan.
191
+ run(["make", "subscriptions"], "subscriptions", args.ledger.parent)
192
+ results["subscriptions"] = {"legacy_make": True}
193
+ else:
194
+ results["subscriptions"] = {"status": "plan-path-required"}
195
+ return {"ok": True, **results}
196
+
197
+
198
+ class FinanceService:
199
+ def __init__(self, runner: CommandRunner):
200
+ self.runner = runner
201
+
202
+ def prepare(self, args):
203
+ return prepare(args, self.runner.run)
204
+
205
+ def finish(self, args):
206
+ return finish(args, self.runner.run)
@@ -0,0 +1,24 @@
1
+ from datetime import date
2
+
3
+ from flova.core.ports import QueryGateway
4
+
5
+
6
+ class LedgerService:
7
+ def __init__(self, gateway: QueryGateway):
8
+ self.gateway = gateway
9
+
10
+ def execute(self, payload):
11
+ if not isinstance(payload, dict):
12
+ raise ValueError("request body must be a JSON object")
13
+ start, end = payload.get("start_date"), payload.get("end_date")
14
+ if not isinstance(start, str) or not isinstance(end, str):
15
+ raise ValueError("start_date and end_date must be strings")
16
+ start, end = date.fromisoformat(start), date.fromisoformat(end)
17
+ if end < start:
18
+ raise ValueError("end_date must not be earlier than start_date")
19
+ if (end - start).days >= 366:
20
+ raise ValueError("date range must not exceed 366 days")
21
+ limit = payload.get("max_transactions", 500)
22
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500:
23
+ raise ValueError("max_transactions must be between 1 and 500")
24
+ return self.gateway.query(start, end, limit)
@@ -0,0 +1,15 @@
1
+ from flova.core.ports import Runtime
2
+
3
+
4
+ class ServiceManager:
5
+ def __init__(self, runtime: Runtime):
6
+ self.runtime = runtime
7
+
8
+ def execute(self, action, output=None, *, keep_stopped=False):
9
+ if keep_stopped and action != "pack":
10
+ raise ValueError("--keep-stopped is only valid for service pack")
11
+ if action == "pack":
12
+ return self.runtime.pack(output, keep_stopped=keep_stopped)
13
+ if action in {"start", "restore", "stop", "check", "status"}:
14
+ return getattr(self.runtime, action)()
15
+ raise ValueError("Unknown service action")
flova/bootstrap.py ADDED
@@ -0,0 +1,35 @@
1
+ from flova.application.deploy import DeploymentService
2
+ from flova.application.finance import FinanceService
3
+ from flova.application.services import ServiceManager
4
+ from flova.infrastructure.git import GitRepository
5
+ from flova.infrastructure.hook import HttpSyncHook
6
+ from flova.infrastructure.process import SubprocessRunner
7
+ from flova.infrastructure.runtime import DockerSystemRuntime
8
+
9
+
10
+ def finance():
11
+ return FinanceService(SubprocessRunner())
12
+
13
+
14
+ def services(config):
15
+ return ServiceManager(DockerSystemRuntime(config.root))
16
+
17
+
18
+ def deployments(config):
19
+ import threading
20
+
21
+ locks = {}
22
+ result = {}
23
+ for hook in config.webhooks.values():
24
+ lock = locks.setdefault(hook.repository.resolve(), threading.Lock())
25
+ result[hook.path] = DeploymentService(
26
+ hook, GitRepository(), HttpSyncHook(hook.after_sync_url, hook.after_sync_token), lock
27
+ )
28
+ return result
29
+
30
+
31
+ def ledger(config):
32
+ from flova.application.ledger import LedgerService
33
+ from flova.infrastructure.beancount import BeancountQuery
34
+
35
+ return LedgerService(BeancountQuery(config.ledger))
flova/config.py ADDED
@@ -0,0 +1,108 @@
1
+ import os
2
+ import re
3
+ import tomllib
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from flova.core.errors import ConfigError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Webhook:
12
+ path: str
13
+ repository: Path
14
+ branch: str
15
+ secret: str = field(repr=False)
16
+ after_sync_url: str = ""
17
+ after_sync_token: str = field(default="", repr=False)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Config:
22
+ root: Path
23
+ webhook_host: str = "0.0.0.0"
24
+ webhook_port: int = 58129
25
+ ledger_host: str = "0.0.0.0"
26
+ ledger_port: int = 8080
27
+ ledger: Path = Path("/bills/main.bean")
28
+ webhooks: dict[str, Webhook] = field(default_factory=dict)
29
+
30
+
31
+ def _expand(value):
32
+ if isinstance(value, str):
33
+
34
+ def replace(match):
35
+ key = match.group(1)
36
+ if key not in os.environ:
37
+ raise ConfigError("Missing environment variable: " + key)
38
+ return os.environ[key]
39
+
40
+ return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, value)
41
+ if isinstance(value, dict):
42
+ return {k: _expand(v) for k, v in value.items()}
43
+ if isinstance(value, list):
44
+ return [_expand(v) for v in value]
45
+ return value
46
+
47
+
48
+ def load_config(path: Path, root: Path | None = None) -> Config:
49
+ try:
50
+ with path.open("rb") as f:
51
+ raw = _expand(tomllib.load(f))
52
+ except (OSError, tomllib.TOMLDecodeError) as error:
53
+ raise ConfigError("Unable to read configuration: " + type(error).__name__) from None
54
+ allowed = {"root", "webhook", "ledger", "webhooks"}
55
+ if set(raw) - allowed:
56
+ raise ConfigError("Unknown configuration sections")
57
+ root = (root or Path(raw.get("root", "/root/.flow"))).absolute()
58
+
59
+ def resolve(value):
60
+ p = Path(value)
61
+ return p if p.is_absolute() else root / p
62
+
63
+ webhook, ledger = raw.get("webhook", {}), raw.get("ledger", {})
64
+ for section, fields in [(webhook, {"host", "port"}), (ledger, {"file", "host", "port"})]:
65
+ if set(section) - fields:
66
+ raise ConfigError("Unknown server configuration fields")
67
+ hooks = {}
68
+ for name, data in raw.get("webhooks", {}).items():
69
+ if set(data) - {
70
+ "path",
71
+ "repository",
72
+ "branch",
73
+ "secret",
74
+ "after_sync_url",
75
+ "after_sync_token",
76
+ }:
77
+ raise ConfigError("Unknown webhook fields: " + name)
78
+ for key in ["path", "repository", "branch", "secret"]:
79
+ if not isinstance(data.get(key), str) or not data[key]:
80
+ raise ConfigError("Missing webhook field: " + name + "." + key)
81
+ if not data["path"].startswith("/"):
82
+ raise ConfigError("Webhook path must start with /")
83
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", data["branch"]):
84
+ raise ConfigError("Invalid Git branch")
85
+ hook = Webhook(
86
+ data["path"],
87
+ resolve(data["repository"]),
88
+ data["branch"],
89
+ data["secret"],
90
+ data.get("after_sync_url", ""),
91
+ data.get("after_sync_token", ""),
92
+ )
93
+ if hook.path in [h.path for h in hooks.values()]:
94
+ raise ConfigError("Duplicate webhook path")
95
+ hooks[name] = hook
96
+ for section in [webhook, ledger]:
97
+ port = section.get("port", 58129 if section is webhook else 8080)
98
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
99
+ raise ConfigError("Invalid server port")
100
+ return Config(
101
+ root,
102
+ webhook.get("host", "0.0.0.0"),
103
+ webhook.get("port", 58129),
104
+ ledger.get("host", "0.0.0.0"),
105
+ ledger.get("port", 8080),
106
+ resolve(ledger.get("file", "data/account/main.bean")),
107
+ hooks,
108
+ )
flova/core/__init__.py ADDED
File without changes
flova/core/errors.py ADDED
@@ -0,0 +1,8 @@
1
+ class ConfigError(ValueError):
2
+ """Invalid local configuration; messages never contain credential values."""
3
+
4
+
5
+ class StageError(RuntimeError):
6
+ def __init__(self, stage, code):
7
+ self.stage, self.code = stage, code
8
+ super().__init__(f"{stage} failed (exit {code}); inspect server logs")
flova/core/ports.py ADDED
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+ from typing import Any, Protocol
3
+
4
+
5
+ class CommandRunner(Protocol):
6
+ def run(self, command: list[str], stage: str, cwd: Path, timeout: int = 180) -> str: ...
7
+
8
+
9
+ class Repository(Protocol):
10
+ def sync(self, path: Path, branch: str) -> bool: ...
11
+
12
+
13
+ class QueryGateway(Protocol):
14
+ def query(self, start, end, limit: int) -> dict[str, Any]: ...
15
+
16
+
17
+ class SyncHook(Protocol):
18
+ def trigger(self) -> None: ...
19
+
20
+
21
+ class Runtime(Protocol):
22
+ def start(self) -> None: ...
23
+ def restore(self) -> None: ...
24
+ def stop(self) -> None: ...
25
+ def status(self) -> None: ...
26
+ def check(self) -> None: ...
27
+ def pack(self, output: Path, *, keep_stopped: bool = False) -> None: ...
File without changes
@@ -0,0 +1,147 @@
1
+ import argparse
2
+ import fcntl
3
+ import json
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ from flova import bootstrap
13
+ from flova.config import load_config
14
+ from flova.core.errors import ConfigError, StageError
15
+ from flova.entrypoints.http import create_server, serve
16
+
17
+
18
+ def parser():
19
+ p = argparse.ArgumentParser(prog="flova")
20
+ p.add_argument("--version", action="version", version="flova " + version("flova"))
21
+ p.add_argument("--root", type=Path, default=None)
22
+ p.add_argument("--config", type=Path)
23
+ commands = p.add_subparsers(dest="group", required=True)
24
+ commands.add_parser("config").add_argument("action", choices=["check"])
25
+ service = commands.add_parser("service")
26
+ service.add_argument("action", choices=["start", "restore", "stop", "check", "status", "pack"])
27
+ service.add_argument("--output", type=Path)
28
+ service.add_argument(
29
+ "--keep-stopped",
30
+ action="store_true",
31
+ help="leave source stopped after a successful migration pack",
32
+ )
33
+ download = commands.add_parser("download")
34
+ download.add_argument("--provider", "-p", default="alipay", choices=["alipay", "wechat"])
35
+ download.add_argument("--download-config", type=Path)
36
+ commands.add_parser("webhook").add_argument("action", choices=["serve"])
37
+ ledger = commands.add_parser("ledger")
38
+ ledger.add_argument("action", choices=["serve", "query"])
39
+ ledger.add_argument("--start")
40
+ ledger.add_argument("--end")
41
+ ledger.add_argument("--max-transactions", type=int, default=500)
42
+ f = commands.add_parser("finance")
43
+ f.add_argument("action", choices=["prepare", "finish"])
44
+ f.add_argument("--flow", type=Path)
45
+ f.add_argument("--date")
46
+ f.add_argument("--offline", action="store_true")
47
+ f.add_argument("--write", action="store_true")
48
+ f.add_argument("--decisions-base64", default="")
49
+ f.add_argument("--subscriptions", type=Path)
50
+ f.add_argument("--ledger", type=Path)
51
+ f.add_argument("--fa")
52
+ f.add_argument("--dflow")
53
+ f.add_argument("--oub")
54
+ return p
55
+
56
+
57
+ def main(argv=None):
58
+ os.umask(0o077)
59
+ args = parser().parse_args(argv)
60
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
61
+ root = args.root or Path(os.environ.get("FLOVA_ROOT", "/root/.flow"))
62
+ path = args.config or Path(os.environ.get("FLOVA_CONFIG", str(root / "config/flova.toml")))
63
+ try:
64
+ config = load_config(path, args.root)
65
+ if args.group == "config":
66
+ print("configuration is valid")
67
+ return 0
68
+ if args.group == "service":
69
+ if os.geteuid() != 0:
70
+ raise ConfigError("Service management requires root")
71
+ output = args.output or config.root / "backups" / (
72
+ "flow-transfer-" + time.strftime("%Y%m%d-%H%M%S") + ".tar.gz"
73
+ )
74
+ bootstrap.services(config).execute(args.action, output, keep_stopped=args.keep_stopped)
75
+ return 0
76
+ if args.group == "download":
77
+ from flova.infrastructure.download import BillDownloader
78
+
79
+ result = BillDownloader().download(
80
+ args.provider, args.download_config or config.root / "config/download.json"
81
+ )
82
+ print(json.dumps(result))
83
+ return 0 if result["status"] == "success" else 1
84
+ if args.group == "webhook":
85
+ if not config.webhooks:
86
+ raise ConfigError("No webhooks configured")
87
+ serve(
88
+ create_server(
89
+ config.webhook_host,
90
+ config.webhook_port,
91
+ deployments=bootstrap.deployments(config),
92
+ )
93
+ )
94
+ return 0
95
+ if args.group == "ledger":
96
+ query = bootstrap.ledger(config)
97
+ if args.action == "serve":
98
+ serve(create_server(config.ledger_host, config.ledger_port, query=query))
99
+ return 0
100
+ print(
101
+ json.dumps(
102
+ query.execute(
103
+ {
104
+ "start_date": args.start,
105
+ "end_date": args.end,
106
+ "max_transactions": args.max_transactions,
107
+ }
108
+ )
109
+ )
110
+ )
111
+ return 0
112
+ args.flow = args.flow or config.root
113
+ args.config = args.flow / "config/fane.yaml"
114
+ args.ledger = args.ledger or args.flow / "data/account/main.bean"
115
+ for name in ["fa", "dflow", "oub"]:
116
+ executable = "flova-download" if name == "dflow" else name
117
+ setattr(args, name, getattr(args, name) or str(args.flow / "runtime/bin" / executable))
118
+ lock = args.flow / "state/locks/n8n-finance.lock"
119
+ lock.parent.mkdir(parents=True, exist_ok=True)
120
+ with lock.open("a") as handle:
121
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
122
+ job = bootstrap.finance()
123
+ result = job.prepare(args) if args.action == "prepare" else job.finish(args)
124
+ print(json.dumps(result, ensure_ascii=False))
125
+ return 0
126
+ except (
127
+ ConfigError,
128
+ StageError,
129
+ RuntimeError,
130
+ OSError,
131
+ ValueError,
132
+ KeyError,
133
+ subprocess.SubprocessError,
134
+ ) as error:
135
+ payload = {
136
+ "ok": False,
137
+ "stage": getattr(error, "stage", args.group),
138
+ "message": str(error)
139
+ if isinstance(error, (ConfigError, StageError, RuntimeError))
140
+ else type(error).__name__,
141
+ }
142
+ print(json.dumps(payload))
143
+ return 1
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from flova.infrastructure.download import BillDownloader
7
+
8
+
9
+ def main(argv=None):
10
+ p = argparse.ArgumentParser(prog="dflow")
11
+ p.add_argument("action", choices=["download"])
12
+ p.add_argument("--provider", "-p", default="alipay", choices=["alipay", "wechat"])
13
+ p.add_argument("--config", type=Path, default=Path("/root/.flow/config/download.json"))
14
+ args = p.parse_args(argv)
15
+ try:
16
+ result = BillDownloader().download(args.provider, args.config)
17
+ except Exception as error:
18
+ result = {"status": "error", "provider": args.provider, "message": type(error).__name__}
19
+ print(json.dumps(result))
20
+ return 0 if result["status"] == "success" else 1
21
+
22
+
23
+ if __name__ == "__main__":
24
+ sys.exit(main())