akt-cli 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.
akt/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """akt — a CLI toolbox for driving an Akaunting accounting instance."""
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
6
+ __version__ = "0.1.0"
akt/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """akt — command-line toolbox for an Akaunting accounting instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Any
8
+
9
+ from .client import ApiError, Client
10
+ from .config import ConfigError, load_config
11
+ from .commands import (
12
+ cmd_create,
13
+ cmd_delete,
14
+ cmd_get,
15
+ cmd_list,
16
+ cmd_toggle,
17
+ cmd_update,
18
+ )
19
+ from .output import emit
20
+ from .registry import RESOURCES, BY_NOUN
21
+ from .resources import Resource, load_data_arg
22
+
23
+
24
+ def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bool) -> None:
25
+ for fld in res.fields:
26
+ flag = f"--{fld.name}"
27
+ if fld.is_flag:
28
+ grp = p.add_mutually_exclusive_group()
29
+ grp.add_argument(f"--{fld.name}", dest=fld.dest, action="store_true",
30
+ default=None, help=fld.help)
31
+ negname = "disabled" if fld.name == "enabled" else f"no-{fld.name}"
32
+ grp.add_argument(f"--{negname}", dest=fld.dest, action="store_false",
33
+ default=None, help=argparse.SUPPRESS)
34
+ else:
35
+ req = fld.required and not for_update and res.build_create is None
36
+ p.add_argument(flag, dest=fld.dest, metavar=fld.dest.upper(),
37
+ required=req, choices=fld.choices, help=fld.help)
38
+ if res.endpoint == "documents":
39
+ p.add_argument("--item", action="append", metavar="K=V,...",
40
+ help="line item, e.g. 'name=Widget,price=10,quantity=2,tax_id=1' (repeatable)")
41
+ p.add_argument("--set", dest="set_", action="append", metavar="KEY=VALUE",
42
+ help="set an arbitrary body field (repeatable; value JSON-coerced)")
43
+ p.add_argument("--data", metavar="JSON|@FILE",
44
+ help="merge raw JSON body (inline or @file) — wins over other flags")
45
+
46
+
47
+ def _build_parser() -> argparse.ArgumentParser:
48
+ # --json lives on a parent parser shared by every subcommand so it works
49
+ # both before the subcommand (akt --json customer list) and after it
50
+ # (akt customer list --json). default=SUPPRESS stops subparser parsing from
51
+ # clobbering a value supplied at the top level.
52
+ common = argparse.ArgumentParser(add_help=False)
53
+ common.add_argument("--json", dest="json", action="store_true", default=argparse.SUPPRESS,
54
+ help="Force raw JSON output")
55
+
56
+ parser = argparse.ArgumentParser(
57
+ prog="akt",
58
+ parents=[common],
59
+ description="Drive an Akaunting accounting instance from the command line.",
60
+ )
61
+ # Connection flags are top-level only (given before the subcommand) so their
62
+ # option strings never collide with a resource's own --email / --currency-code
63
+ # etc. Distinct conn_* dests also keep them out of the field namespace.
64
+ parser.add_argument("--base-url", dest="conn_base_url",
65
+ help="API base URL (or AKT_BASE_URL / APP_URL)")
66
+ parser.add_argument("--email", dest="conn_email", help="Admin email (or AKT_EMAIL)")
67
+ parser.add_argument("--password", dest="conn_password", help="Admin password (or AKT_PASSWORD)")
68
+ parser.add_argument("--company", dest="conn_company", help="Company id (default 1, or AKT_COMPANY)")
69
+ parser.add_argument("--throttle", dest="conn_throttle", type=float, default=None,
70
+ metavar="SECONDS",
71
+ help="Min seconds between API calls (or AKT_THROTTLE). "
72
+ "Use a value like 1.0 to avoid tripping host bot-protection.")
73
+
74
+ sub = parser.add_subparsers(dest="resource", metavar="<resource>")
75
+
76
+ for res in RESOURCES:
77
+ rp = sub.add_parser(res.noun, help=res.help)
78
+ verbs = rp.add_subparsers(dest="verb", metavar="<verb>")
79
+
80
+ lp = verbs.add_parser("list", parents=[common], help=f"List {res.noun}s")
81
+ lp.add_argument("--search", default="", help="search-string filter (e.g. 'name:Acme')")
82
+ lp.add_argument("--all", action="store_true", help="fetch all pages")
83
+ lp.add_argument("--limit", type=int, help="records per page")
84
+ lp.set_defaults(_handler=lambda res, c, ns: cmd_list(res, c, ns))
85
+
86
+ gp = verbs.add_parser("get", parents=[common], help=f"Show one {res.noun} by id")
87
+ gp.add_argument("id")
88
+ gp.set_defaults(_handler=lambda res, c, ns: cmd_get(res, c, ns))
89
+
90
+ cp = verbs.add_parser("create", parents=[common], help=f"Create a {res.noun}")
91
+ _add_field_args(cp, res, for_update=False)
92
+ cp.set_defaults(_handler=lambda res, c, ns: cmd_create(res, c, ns))
93
+
94
+ up = verbs.add_parser("update", parents=[common], help=f"Update a {res.noun}")
95
+ up.add_argument("id")
96
+ _add_field_args(up, res, for_update=True)
97
+ up.set_defaults(_handler=lambda res, c, ns: cmd_update(res, c, ns))
98
+
99
+ dp = verbs.add_parser("delete", parents=[common], help=f"Delete a {res.noun}")
100
+ dp.add_argument("id")
101
+ dp.set_defaults(_handler=lambda res, c, ns: cmd_delete(res, c, ns))
102
+
103
+ if res.supports_toggle:
104
+ ep = verbs.add_parser("enable", parents=[common], help=f"Enable a {res.noun}")
105
+ ep.add_argument("id")
106
+ ep.set_defaults(_handler=lambda res, c, ns: cmd_toggle(res, c, ns, True))
107
+ xp = verbs.add_parser("disable", parents=[common], help=f"Disable a {res.noun}")
108
+ xp.add_argument("id")
109
+ xp.set_defaults(_handler=lambda res, c, ns: cmd_toggle(res, c, ns, False))
110
+
111
+ # ---- non-resource utility commands ----
112
+ pp = sub.add_parser("ping", parents=[common], help="Health check (unauthenticated)")
113
+ pp.set_defaults(_special="ping")
114
+
115
+ cp = sub.add_parser("company", parents=[common], help="List companies / show current")
116
+ cp.set_defaults(_special="company")
117
+
118
+ sp = sub.add_parser("settings", parents=[common], help="List company settings")
119
+ sp.add_argument("--search", default="", help="e.g. 'key:default.account'")
120
+ sp.set_defaults(_special="settings")
121
+
122
+ rp = sub.add_parser("raw", parents=[common], help="Call an arbitrary API endpoint")
123
+ rp.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE",
124
+ "get", "post", "put", "patch", "delete"])
125
+ rp.add_argument("path", help="endpoint path, e.g. 'items' or 'documents/5'")
126
+ rp.add_argument("--data", metavar="JSON|@FILE", help="request body (inline JSON or @file)")
127
+ rp.add_argument("--query", action="append", metavar="K=V", help="query param (repeatable)")
128
+ rp.add_argument("--type-scope", help="search=type:X scope for contacts/documents")
129
+ rp.set_defaults(_special="raw")
130
+
131
+ return parser
132
+
133
+
134
+ def _run_special(name: str, client: Client, ns: Any) -> int:
135
+ if name == "ping":
136
+ emit(client.get("ping"), as_json=True)
137
+ return 0
138
+ if name == "company":
139
+ rows = client.list("companies")
140
+ cols = ["id", "name", "email", "currency", "enabled"]
141
+ emit(rows, as_json=ns.json, columns=None if ns.json else cols,
142
+ headers=["ID", "Name", "Email", "Currency", "Enabled"])
143
+ return 0
144
+ if name == "settings":
145
+ rows = client.list("settings", search=ns.search or None, all_pages=True)
146
+ cols = ["id", "key", "value"]
147
+ emit(rows, as_json=ns.json, columns=None if ns.json else cols,
148
+ headers=["ID", "Key", "Value"])
149
+ return 0
150
+ if name == "raw":
151
+ params = dict(kv.split("=", 1) for kv in (ns.query or []))
152
+ body = load_data_arg(ns.data) if ns.data else None
153
+ result = client.request(ns.method.upper(), ns.path, params=params or None,
154
+ json_body=body, type_scope=ns.type_scope)
155
+ emit(result, as_json=True)
156
+ return 0
157
+ raise ValueError(name)
158
+
159
+
160
+ def main(argv: list[str] | None = None) -> int:
161
+ parser = _build_parser()
162
+ ns = parser.parse_args(argv)
163
+
164
+ if not getattr(ns, "resource", None):
165
+ parser.print_help()
166
+ return 1
167
+
168
+ # Flags on the shared parent use default=SUPPRESS; backfill them here.
169
+ ns.json = getattr(ns, "json", False)
170
+
171
+ try:
172
+ config = load_config(
173
+ base_url=getattr(ns, "conn_base_url", None),
174
+ email=getattr(ns, "conn_email", None),
175
+ password=getattr(ns, "conn_password", None),
176
+ company=getattr(ns, "conn_company", None),
177
+ )
178
+ except ConfigError as e:
179
+ print(f"config error: {e}", file=sys.stderr)
180
+ return 2
181
+
182
+ import os
183
+ throttle = getattr(ns, "conn_throttle", None)
184
+ if throttle is None:
185
+ throttle = float(os.environ.get("AKT_THROTTLE", "0") or 0)
186
+ client = Client(config, throttle=throttle)
187
+
188
+ try:
189
+ special = getattr(ns, "_special", None)
190
+ if special:
191
+ return _run_special(special, client, ns)
192
+
193
+ res = BY_NOUN[ns.resource]
194
+ handler = getattr(ns, "_handler", None)
195
+ if handler is None:
196
+ # resource given without a verb
197
+ sub = next(a for a in parser._subparsers._actions # type: ignore[attr-defined]
198
+ if a.dest == "resource")
199
+ sub.choices[ns.resource].print_help() # type: ignore[union-attr]
200
+ return 1
201
+ return handler(res, client, ns)
202
+ except ApiError as e:
203
+ print(str(e), file=sys.stderr)
204
+ return 1
205
+ except (ValueError, OSError) as e:
206
+ print(f"error: {e}", file=sys.stderr)
207
+ return 1
208
+
209
+
210
+ if __name__ == "__main__":
211
+ raise SystemExit(main())
akt/client.py ADDED
@@ -0,0 +1,240 @@
1
+ """Thin HTTP client for the Akaunting REST API.
2
+
3
+ Akaunting specifics baked in here:
4
+ * HTTP Basic auth (admin email + password).
5
+ * Every company-scoped request carries ``company_id`` as a query param.
6
+ * The ``contacts`` and ``documents`` controllers derive their ACL permission
7
+ from a ``search=type:<x>`` query param, so for those endpoints the caller
8
+ must pass ``type_scope`` on *every* verb (GET/POST/PUT/DELETE) or the API
9
+ returns 403 "necessary access rights".
10
+ * Responses are JSON-API-ish: a single object under ``data`` for show/create,
11
+ a list under ``data`` plus ``meta`` pagination for index.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from typing import Any, Iterator
19
+
20
+ import requests
21
+
22
+ from .config import Config
23
+
24
+ # Imunify360 / generic WAF + throttle responses we transparently retry.
25
+ _RETRY_STATUS = {429, 503}
26
+ _WAF_MARKERS = ("imunify360", "bot-protection", "bot protection", "access denied by")
27
+ _RETRY_BACKOFF = [2.0, 5.0, 10.0, 20.0]
28
+
29
+
30
+ class ApiError(Exception):
31
+ """An error returned by the Akaunting API (non-2xx)."""
32
+
33
+ def __init__(self, status: int, message: str, errors: dict | None = None, body: Any = None):
34
+ self.status = status
35
+ self.message = message
36
+ self.errors = errors or {}
37
+ self.body = body
38
+ super().__init__(self._format())
39
+
40
+ def _format(self) -> str:
41
+ out = f"HTTP {self.status}: {self.message}"
42
+ for field, msgs in self.errors.items():
43
+ if isinstance(msgs, list):
44
+ for m in msgs:
45
+ out += f"\n - {field}: {m}"
46
+ else:
47
+ out += f"\n - {field}: {msgs}"
48
+ return out
49
+
50
+
51
+ def _is_transient(resp: requests.Response) -> bool:
52
+ """True for throttle / WAF responses worth retrying."""
53
+ if resp.status_code in _RETRY_STATUS:
54
+ return True
55
+ body = (resp.text or "").lower()
56
+ return any(m in body for m in _WAF_MARKERS)
57
+
58
+
59
+ class Client:
60
+ def __init__(self, config: Config, *, timeout: float = 30.0, max_retries: int = 4,
61
+ throttle: float = 0.0):
62
+ self.config = config
63
+ self.timeout = timeout
64
+ self.max_retries = max_retries
65
+ self.throttle = throttle # min seconds between requests (anti-WAF)
66
+ self._last_request = 0.0
67
+ self._settings_cache: dict[str, Any] = {}
68
+ self._settings_loaded = False
69
+ self._session = requests.Session()
70
+ self._session.auth = (config.email, config.password)
71
+ self._session.headers.update(
72
+ {
73
+ "Accept": "application/json",
74
+ "Content-Type": "application/json",
75
+ "User-Agent": "akt/0.1 (+akaunting-cli)",
76
+ }
77
+ )
78
+
79
+ # ---- low level -----------------------------------------------------
80
+
81
+ def request(
82
+ self,
83
+ method: str,
84
+ path: str,
85
+ *,
86
+ params: dict | None = None,
87
+ json_body: Any = None,
88
+ type_scope: str | None = None,
89
+ ) -> Any:
90
+ url = f"{self.config.api_root}/{path.lstrip('/')}"
91
+ query: dict[str, Any] = {"company_id": self.config.company_id}
92
+ if type_scope:
93
+ # merge into a search-string; preserve any caller-provided search
94
+ existing = (params or {}).get("search", "")
95
+ scope = f"type:{type_scope}"
96
+ query["search"] = f"{scope} {existing}".strip() if existing else scope
97
+ if params:
98
+ for k, v in params.items():
99
+ if v is None:
100
+ continue
101
+ if k == "search" and type_scope:
102
+ continue # already merged
103
+ query[k] = v
104
+
105
+ data = None
106
+ if json_body is not None:
107
+ data = json.dumps(json_body)
108
+
109
+ attempt = 0
110
+ while True:
111
+ if self.throttle > 0:
112
+ wait = self.throttle - (time.monotonic() - self._last_request)
113
+ if wait > 0:
114
+ time.sleep(wait)
115
+ self._last_request = time.monotonic()
116
+ resp = self._session.request(
117
+ method.upper(),
118
+ url,
119
+ params=query,
120
+ data=data,
121
+ timeout=self.timeout,
122
+ )
123
+ if attempt < self.max_retries and _is_transient(resp):
124
+ delay = _RETRY_BACKOFF[min(attempt, len(_RETRY_BACKOFF) - 1)]
125
+ time.sleep(delay)
126
+ attempt += 1
127
+ continue
128
+ return self._handle(resp)
129
+
130
+ @staticmethod
131
+ def _waf_blocked(resp: requests.Response) -> bool:
132
+ body = (resp.text or "").lower()
133
+ return any(m in body for m in _WAF_MARKERS)
134
+
135
+ def _handle(self, resp: requests.Response) -> Any:
136
+ if self._waf_blocked(resp):
137
+ raise ApiError(
138
+ resp.status_code,
139
+ "Blocked by Imunify360 bot-protection after retries. "
140
+ "Whitelist this machine's public IP in the host's Imunify360 / "
141
+ "cPanel firewall, or retry later.",
142
+ )
143
+ if resp.status_code == 204 or not resp.content:
144
+ if resp.ok:
145
+ return None
146
+ raise ApiError(resp.status_code, resp.reason or "Request failed")
147
+ try:
148
+ payload = resp.json()
149
+ except ValueError:
150
+ if resp.ok:
151
+ return resp.text
152
+ raise ApiError(resp.status_code, resp.text[:500] or resp.reason or "Request failed")
153
+
154
+ if not resp.ok:
155
+ message = "Request failed"
156
+ errors = None
157
+ if isinstance(payload, dict):
158
+ message = payload.get("message") or payload.get("error") or message
159
+ errors = payload.get("errors")
160
+ raise ApiError(resp.status_code, message, errors, payload)
161
+ return payload
162
+
163
+ # ---- convenience verbs --------------------------------------------
164
+
165
+ def get(self, path: str, **kw) -> Any:
166
+ return self.request("GET", path, **kw)
167
+
168
+ def post(self, path: str, json_body: Any, **kw) -> Any:
169
+ return self.request("POST", path, json_body=json_body, **kw)
170
+
171
+ def put(self, path: str, json_body: Any, **kw) -> Any:
172
+ return self.request("PUT", path, json_body=json_body, **kw)
173
+
174
+ def delete(self, path: str, **kw) -> Any:
175
+ return self.request("DELETE", path, **kw)
176
+
177
+ # ---- higher level helpers -----------------------------------------
178
+
179
+ def list(
180
+ self,
181
+ path: str,
182
+ *,
183
+ type_scope: str | None = None,
184
+ search: str | None = None,
185
+ params: dict | None = None,
186
+ all_pages: bool = False,
187
+ limit: int | None = None,
188
+ ) -> list[dict]:
189
+ """Return the ``data`` list. Optionally follow pagination."""
190
+ p: dict[str, Any] = dict(params or {})
191
+ if search:
192
+ p["search"] = search
193
+ if limit:
194
+ p["limit"] = limit
195
+ page = 1
196
+ out: list[dict] = []
197
+ while True:
198
+ p["page"] = page
199
+ payload = self.get(path, params=p, type_scope=type_scope)
200
+ data = payload.get("data", []) if isinstance(payload, dict) else []
201
+ out.extend(data)
202
+ if not all_pages:
203
+ break
204
+ meta = payload.get("meta", {}) if isinstance(payload, dict) else {}
205
+ last = meta.get("last_page", page)
206
+ if page >= last:
207
+ break
208
+ page += 1
209
+ return out
210
+
211
+ def iter_pages(self, path: str, *, type_scope: str | None = None, search: str | None = None,
212
+ params: dict | None = None) -> Iterator[dict]:
213
+ p: dict[str, Any] = dict(params or {})
214
+ if search:
215
+ p["search"] = search
216
+ page = 1
217
+ while True:
218
+ p["page"] = page
219
+ payload = self.get(path, params=p, type_scope=type_scope)
220
+ for row in (payload.get("data", []) if isinstance(payload, dict) else []):
221
+ yield row
222
+ meta = payload.get("meta", {}) if isinstance(payload, dict) else {}
223
+ last = meta.get("last_page", page)
224
+ if page >= last:
225
+ break
226
+ page += 1
227
+
228
+ def show(self, path: str, ident: str | int, *, type_scope: str | None = None) -> dict:
229
+ payload = self.get(f"{path}/{ident}", type_scope=type_scope)
230
+ if isinstance(payload, dict) and "data" in payload:
231
+ return payload["data"]
232
+ return payload
233
+
234
+ def setting(self, key: str, default: Any = None) -> Any:
235
+ """Read a single company setting value by key (cached after first call)."""
236
+ if not self._settings_loaded:
237
+ rows = self.list("settings", all_pages=True)
238
+ self._settings_cache = {str(r.get("key")): r.get("value") for r in rows}
239
+ self._settings_loaded = True
240
+ return self._settings_cache.get(key, default)
akt/commands.py ADDED
@@ -0,0 +1,88 @@
1
+ """Generic command handlers shared by every resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import Client
8
+ from .output import emit
9
+ from .resources import Resource, body_from_fields
10
+
11
+
12
+ def cmd_list(res: Resource, client: Client, ns: Any) -> int:
13
+ search = ns.search
14
+ if res.search_default:
15
+ search = f"{res.search_default} {search}".strip() if search else res.search_default
16
+ rows = client.list(
17
+ res.endpoint,
18
+ type_scope=res.type_scope,
19
+ search=search or None,
20
+ all_pages=ns.all,
21
+ limit=ns.limit,
22
+ )
23
+ cols = [c[1] for c in res.columns]
24
+ heads = [c[0] for c in res.columns]
25
+ emit(rows, as_json=ns.json, columns=cols if not ns.json else None, headers=heads)
26
+ return 0
27
+
28
+
29
+ def cmd_get(res: Resource, client: Client, ns: Any) -> int:
30
+ row = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
31
+ emit(row, as_json=True)
32
+ return 0
33
+
34
+
35
+ def cmd_create(res: Resource, client: Client, ns: Any) -> int:
36
+ if res.build_create:
37
+ body = res.build_create(res, client, ns)
38
+ else:
39
+ body = body_from_fields(res, ns, for_update=False)
40
+ _require(res, body)
41
+ # A builder may override the target route via reserved keys (e.g. paying a
42
+ # document goes to documents/{id}/transactions).
43
+ endpoint = body.pop("__endpoint__", res.endpoint)
44
+ type_scope = body.pop("__type_scope__", res.type_scope)
45
+ payload = client.post(endpoint, body, type_scope=type_scope)
46
+ data = payload.get("data", payload) if isinstance(payload, dict) else payload
47
+ emit(data, as_json=True)
48
+ return 0
49
+
50
+
51
+ def cmd_update(res: Resource, client: Client, ns: Any) -> int:
52
+ if res.build_update:
53
+ current = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
54
+ body = res.build_update(res, client, ns, current)
55
+ else:
56
+ # PUT is a full replace in Akaunting, so merge changes onto the current
57
+ # record to satisfy required-field validation.
58
+ current = client.show(res.endpoint, ns.id, type_scope=res.type_scope)
59
+ body = body_from_fields(res, ns, for_update=True, current=current)
60
+ payload = client.put(f"{res.endpoint}/{ns.id}", body, type_scope=res.type_scope)
61
+ data = payload.get("data", payload) if isinstance(payload, dict) else payload
62
+ emit(data, as_json=True)
63
+ return 0
64
+
65
+
66
+ def cmd_delete(res: Resource, client: Client, ns: Any) -> int:
67
+ if res.delete_resolver:
68
+ path, type_scope = res.delete_resolver(res, client, str(ns.id))
69
+ else:
70
+ path, type_scope = f"{res.endpoint}/{ns.id}", res.type_scope
71
+ client.delete(path, type_scope=type_scope)
72
+ print(f"deleted {res.noun} {ns.id}")
73
+ return 0
74
+
75
+
76
+ def cmd_toggle(res: Resource, client: Client, ns: Any, enable: bool) -> int:
77
+ # Akaunting exposes GET enable/disable endpoints
78
+ action = "enable" if enable else "disable"
79
+ payload = client.get(f"{res.endpoint}/{ns.id}/{action}", type_scope=res.type_scope)
80
+ data = payload.get("data", payload) if isinstance(payload, dict) else payload
81
+ emit(data, as_json=True)
82
+ return 0
83
+
84
+
85
+ def _require(res: Resource, body: dict) -> None:
86
+ missing = [fld.dest for fld in res.fields if fld.required and fld.dest not in body]
87
+ if missing:
88
+ raise ValueError(f"missing required field(s): {', '.join(missing)}")
akt/config.py ADDED
@@ -0,0 +1,121 @@
1
+ """Configuration loading for akt.
2
+
3
+ Resolution order (highest priority first):
4
+ 1. Explicit CLI flags (--base-url, --email, --password, --company)
5
+ 2. Environment variables (AKT_BASE_URL, AKT_EMAIL, AKT_PASSWORD, AKT_COMPANY)
6
+ 3. A dotenv file: $AKT_ENV_FILE, or ./.env, or ~/.config/akt/akt.env
7
+ Recognised keys: APP_URL / AKT_BASE_URL, AKAUNTING_ADMIN_EMAIL / AKT_EMAIL,
8
+ AKAUNTING_ADMIN_PASSWORD / AKT_PASSWORD, AKT_COMPANY.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ def _parse_dotenv(path: Path) -> dict[str, str]:
19
+ data: dict[str, str] = {}
20
+ try:
21
+ text = path.read_text()
22
+ except OSError:
23
+ return data
24
+ for raw in text.splitlines():
25
+ line = raw.strip()
26
+ if not line or line.startswith("#") or "=" not in line:
27
+ continue
28
+ key, _, val = line.partition("=")
29
+ key = key.strip()
30
+ val = val.strip().strip('"').strip("'")
31
+ if key:
32
+ data[key] = val
33
+ return data
34
+
35
+
36
+ def _candidate_env_files() -> list[Path]:
37
+ files: list[Path] = []
38
+ if os.environ.get("AKT_ENV_FILE"):
39
+ files.append(Path(os.environ["AKT_ENV_FILE"]).expanduser())
40
+ files.append(Path.cwd() / ".env")
41
+ files.append(Path.home() / ".config" / "akt" / "akt.env")
42
+ return files
43
+
44
+
45
+ class ConfigError(Exception):
46
+ pass
47
+
48
+
49
+ @dataclass
50
+ class Config:
51
+ base_url: str
52
+ email: str
53
+ password: str
54
+ company_id: int
55
+
56
+ @property
57
+ def api_root(self) -> str:
58
+ base = self.base_url.rstrip("/")
59
+ if base.endswith("/api"):
60
+ return base
61
+ return base + "/api"
62
+
63
+
64
+ def load_config(
65
+ *,
66
+ base_url: str | None = None,
67
+ email: str | None = None,
68
+ password: str | None = None,
69
+ company: int | str | None = None,
70
+ ) -> Config:
71
+ """Merge CLI args, environment, and dotenv files into a Config."""
72
+ filevals: dict[str, str] = {}
73
+ for f in _candidate_env_files():
74
+ for k, v in _parse_dotenv(f).items():
75
+ filevals.setdefault(k, v) # earlier files win
76
+
77
+ def pick(cli, *keys, default=None):
78
+ if cli is not None and cli != "":
79
+ return cli
80
+ for k in keys:
81
+ if os.environ.get(k):
82
+ return os.environ[k]
83
+ for k in keys:
84
+ if filevals.get(k):
85
+ return filevals[k]
86
+ return default
87
+
88
+ resolved_base = pick(base_url, "AKT_BASE_URL", "APP_URL")
89
+ resolved_email = pick(email, "AKT_EMAIL", "AKAUNTING_ADMIN_EMAIL")
90
+ resolved_password = pick(password, "AKT_PASSWORD", "AKAUNTING_ADMIN_PASSWORD")
91
+ resolved_company = pick(company, "AKT_COMPANY", default="1")
92
+
93
+ missing = [
94
+ name
95
+ for name, val in [
96
+ ("base url", resolved_base),
97
+ ("email", resolved_email),
98
+ ("password", resolved_password),
99
+ ]
100
+ if not val
101
+ ]
102
+ if missing:
103
+ raise ConfigError(
104
+ "Missing required configuration: "
105
+ + ", ".join(missing)
106
+ + ".\nSet them via flags (--base-url/--email/--password), env vars "
107
+ "(AKT_BASE_URL/AKT_EMAIL/AKT_PASSWORD), or a .env file."
108
+ )
109
+
110
+ try:
111
+ company_id = int(resolved_company)
112
+ except (TypeError, ValueError):
113
+ raise ConfigError(f"Invalid company id: {resolved_company!r}")
114
+
115
+ assert resolved_base and resolved_email and resolved_password # guarded above
116
+ return Config(
117
+ base_url=resolved_base,
118
+ email=resolved_email,
119
+ password=resolved_password,
120
+ company_id=company_id,
121
+ )