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 +6 -0
- akt/cli.py +211 -0
- akt/client.py +240 -0
- akt/commands.py +88 -0
- akt/config.py +121 -0
- akt/output.py +84 -0
- akt/registry.py +263 -0
- akt/resources.py +467 -0
- akt_cli-0.1.0.dist-info/METADATA +274 -0
- akt_cli-0.1.0.dist-info/RECORD +13 -0
- akt_cli-0.1.0.dist-info/WHEEL +4 -0
- akt_cli-0.1.0.dist-info/entry_points.txt +3 -0
- akt_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
akt/__init__.py
ADDED
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
|
+
)
|