aegro 0.2.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.
- aegro/__init__.py +4 -0
- aegro/_version.py +34 -0
- aegro/api_client.py +128 -0
- aegro/cli/__init__.py +64 -0
- aegro/cli/_auth.py +110 -0
- aegro/cli/_client.py +72 -0
- aegro/cli/_config.py +101 -0
- aegro/cli/_errors.py +44 -0
- aegro/cli/_output.py +82 -0
- aegro/cli/activities.py +276 -0
- aegro/cli/assets.py +432 -0
- aegro/cli/auth.py +92 -0
- aegro/cli/bank_accounts.py +147 -0
- aegro/cli/catalogs.py +113 -0
- aegro/cli/companies.py +136 -0
- aegro/cli/crop_glebes.py +73 -0
- aegro/cli/crops.py +219 -0
- aegro/cli/elements.py +311 -0
- aegro/cli/farms.py +81 -0
- aegro/cli/financial.py +278 -0
- aegro/cli/financial_categories.py +180 -0
- aegro/cli/fuel_supplies.py +184 -0
- aegro/cli/glebes.py +68 -0
- aegro/cli/harvest_logs.py +131 -0
- aegro/cli/maintenances.py +184 -0
- aegro/cli/purchase_orders.py +153 -0
- aegro/cli/stock.py +351 -0
- aegro/cli/tags.py +119 -0
- aegro/cli/weather.py +116 -0
- aegro/config.py +34 -0
- aegro/errors.py +72 -0
- aegro/validation.py +17 -0
- aegro-0.2.0.dist-info/METADATA +240 -0
- aegro-0.2.0.dist-info/RECORD +37 -0
- aegro-0.2.0.dist-info/WHEEL +4 -0
- aegro-0.2.0.dist-info/entry_points.txt +2 -0
- aegro-0.2.0.dist-info/licenses/LICENSE +21 -0
aegro/__init__.py
ADDED
aegro/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
aegro/api_client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from aegro.errors import AegroAPIError, translate_api_error
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AegroClient:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
base_url: str = "https://app.aegro.com.br",
|
|
17
|
+
timeout: float = 30.0,
|
|
18
|
+
max_retries: int = 3,
|
|
19
|
+
):
|
|
20
|
+
self._base_url = base_url.rstrip("/")
|
|
21
|
+
self._timeout = timeout
|
|
22
|
+
self._max_retries = max_retries
|
|
23
|
+
self._http: httpx.AsyncClient | None = None
|
|
24
|
+
|
|
25
|
+
def _get_http(self) -> httpx.AsyncClient:
|
|
26
|
+
if self._http is None or self._http.is_closed:
|
|
27
|
+
self._http = httpx.AsyncClient(timeout=self._timeout)
|
|
28
|
+
return self._http
|
|
29
|
+
|
|
30
|
+
async def close(self) -> None:
|
|
31
|
+
if self._http is not None and not self._http.is_closed:
|
|
32
|
+
await self._http.aclose()
|
|
33
|
+
self._http = None
|
|
34
|
+
|
|
35
|
+
def _headers(self, api_key: str) -> dict[str, str]:
|
|
36
|
+
return {
|
|
37
|
+
"Aegro-Public-API-Key": api_key,
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async def _request(
|
|
43
|
+
self, method: str, path: str, api_key: str, json_body: dict | None = None
|
|
44
|
+
) -> dict | list | None:
|
|
45
|
+
url = f"{self._base_url}{path}"
|
|
46
|
+
attempt = 0
|
|
47
|
+
last_response = None
|
|
48
|
+
http = self._get_http()
|
|
49
|
+
|
|
50
|
+
while attempt <= self._max_retries:
|
|
51
|
+
try:
|
|
52
|
+
response = await http.request(
|
|
53
|
+
method, url, headers=self._headers(api_key), json=json_body
|
|
54
|
+
)
|
|
55
|
+
last_response = response
|
|
56
|
+
if response.status_code == 204:
|
|
57
|
+
return None
|
|
58
|
+
if response.status_code in (200, 201):
|
|
59
|
+
return response.json()
|
|
60
|
+
if response.status_code >= 500 and attempt < self._max_retries:
|
|
61
|
+
attempt += 1
|
|
62
|
+
logger.warning(
|
|
63
|
+
"aegro_api_retry", path=path, status=response.status_code, attempt=attempt
|
|
64
|
+
)
|
|
65
|
+
await asyncio.sleep(2**attempt)
|
|
66
|
+
continue
|
|
67
|
+
body = {}
|
|
68
|
+
try:
|
|
69
|
+
body = response.json()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
raise translate_api_error(response.status_code, body)
|
|
73
|
+
except httpx.TimeoutException:
|
|
74
|
+
if attempt < self._max_retries:
|
|
75
|
+
attempt += 1
|
|
76
|
+
logger.warning("aegro_api_timeout", path=path, attempt=attempt)
|
|
77
|
+
await asyncio.sleep(2**attempt)
|
|
78
|
+
continue
|
|
79
|
+
raise AegroAPIError(
|
|
80
|
+
504, "Timeout ao conectar com o servico Aegro. Tente novamente.", retryable=True
|
|
81
|
+
)
|
|
82
|
+
except AegroAPIError:
|
|
83
|
+
raise
|
|
84
|
+
except httpx.HTTPError as exc:
|
|
85
|
+
raise AegroAPIError(
|
|
86
|
+
502,
|
|
87
|
+
f"Erro de conexao com o servico Aegro: {type(exc).__name__}",
|
|
88
|
+
retryable=True,
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
if last_response is not None:
|
|
92
|
+
body = {}
|
|
93
|
+
try:
|
|
94
|
+
body = last_response.json()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
raise translate_api_error(last_response.status_code, body)
|
|
98
|
+
raise AegroAPIError(502, "Erro inesperado de conexao.", retryable=True)
|
|
99
|
+
|
|
100
|
+
async def get(self, path: str, *, api_key: str) -> dict | list | None:
|
|
101
|
+
return await self._request("GET", path, api_key)
|
|
102
|
+
|
|
103
|
+
async def post(
|
|
104
|
+
self, path: str, *, api_key: str, json_body: dict | None = None
|
|
105
|
+
) -> dict | list | None:
|
|
106
|
+
return await self._request("POST", path, api_key, json_body)
|
|
107
|
+
|
|
108
|
+
async def put(
|
|
109
|
+
self, path: str, *, api_key: str, json_body: dict | None = None
|
|
110
|
+
) -> dict | list | None:
|
|
111
|
+
return await self._request("PUT", path, api_key, json_body)
|
|
112
|
+
|
|
113
|
+
async def delete(self, path: str, *, api_key: str) -> dict | list | None:
|
|
114
|
+
return await self._request("DELETE", path, api_key)
|
|
115
|
+
|
|
116
|
+
async def get_user_identity(self, api_key: str) -> str | None:
|
|
117
|
+
"""Return the email address associated with the given API key.
|
|
118
|
+
|
|
119
|
+
Calls ``/pub/v1/users/me`` and extracts the ``email`` field.
|
|
120
|
+
Returns ``None`` when the endpoint does not return a usable email.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
data = await self.get("/pub/v1/users/me", api_key=api_key)
|
|
124
|
+
if isinstance(data, dict):
|
|
125
|
+
return data.get("email")
|
|
126
|
+
except (AegroAPIError, httpx.HTTPError):
|
|
127
|
+
logger.warning("identity_lookup_failed", exc_info=True)
|
|
128
|
+
return None
|
aegro/cli/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="aegro",
|
|
7
|
+
help="CLI para gerenciamento agricola via API Aegro.",
|
|
8
|
+
invoke_without_command=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def _callback(ctx: typer.Context) -> None:
|
|
14
|
+
"""CLI para gerenciamento agricola via API Aegro."""
|
|
15
|
+
if ctx.invoked_subcommand is None:
|
|
16
|
+
typer.echo(ctx.get_help())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from aegro.cli.activities import activities_app # noqa: E402
|
|
20
|
+
from aegro.cli.assets import assets_app # noqa: E402
|
|
21
|
+
from aegro.cli.auth import auth_app # noqa: E402
|
|
22
|
+
from aegro.cli.bank_accounts import bank_accounts_app # noqa: E402
|
|
23
|
+
from aegro.cli.catalogs import catalogs_app # noqa: E402
|
|
24
|
+
from aegro.cli.companies import companies_app # noqa: E402
|
|
25
|
+
from aegro.cli.crop_glebes import crop_glebes_app # noqa: E402
|
|
26
|
+
from aegro.cli.crops import crops_app # noqa: E402
|
|
27
|
+
from aegro.cli.elements import elements_app # noqa: E402
|
|
28
|
+
from aegro.cli.farms import farms_app # noqa: E402
|
|
29
|
+
from aegro.cli.financial import financial_app # noqa: E402
|
|
30
|
+
from aegro.cli.financial_categories import fin_categories_app # noqa: E402
|
|
31
|
+
from aegro.cli.fuel_supplies import fuel_supplies_app # noqa: E402
|
|
32
|
+
from aegro.cli.glebes import glebes_app # noqa: E402
|
|
33
|
+
from aegro.cli.harvest_logs import harvest_logs_app # noqa: E402
|
|
34
|
+
from aegro.cli.maintenances import maintenances_app # noqa: E402
|
|
35
|
+
from aegro.cli.purchase_orders import purchase_orders_app # noqa: E402
|
|
36
|
+
from aegro.cli.stock import stock_app # noqa: E402
|
|
37
|
+
from aegro.cli.tags import tags_app # noqa: E402
|
|
38
|
+
from aegro.cli.weather import weather_app # noqa: E402
|
|
39
|
+
|
|
40
|
+
app.add_typer(activities_app, name="activities")
|
|
41
|
+
app.add_typer(assets_app, name="assets")
|
|
42
|
+
app.add_typer(auth_app, name="auth")
|
|
43
|
+
app.add_typer(bank_accounts_app, name="bank-accounts")
|
|
44
|
+
app.add_typer(catalogs_app, name="catalogs")
|
|
45
|
+
app.add_typer(companies_app, name="companies")
|
|
46
|
+
app.add_typer(crop_glebes_app, name="crop-glebes")
|
|
47
|
+
app.add_typer(crops_app, name="crops")
|
|
48
|
+
app.add_typer(elements_app, name="elements")
|
|
49
|
+
app.add_typer(farms_app, name="farms")
|
|
50
|
+
app.add_typer(financial_app, name="financial")
|
|
51
|
+
app.add_typer(fin_categories_app, name="fin-categories")
|
|
52
|
+
app.add_typer(fuel_supplies_app, name="fuel-supplies")
|
|
53
|
+
app.add_typer(glebes_app, name="glebes")
|
|
54
|
+
app.add_typer(harvest_logs_app, name="harvest-logs")
|
|
55
|
+
app.add_typer(maintenances_app, name="maintenances")
|
|
56
|
+
app.add_typer(purchase_orders_app, name="purchase-orders")
|
|
57
|
+
app.add_typer(stock_app, name="stock")
|
|
58
|
+
app.add_typer(tags_app, name="tags")
|
|
59
|
+
app.add_typer(weather_app, name="weather")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
"""Entry point for the aegro CLI."""
|
|
64
|
+
app()
|
aegro/cli/_auth.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path.home() / ".config" / "aegro"
|
|
10
|
+
CREDENTIALS_FILE_NAME = "credentials.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _warn(msg: str) -> None:
|
|
14
|
+
print(f"aegro: aviso: {msg}", file=sys.stderr)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_credentials() -> dict[str, str]:
|
|
18
|
+
"""Load farm name -> API key mapping.
|
|
19
|
+
|
|
20
|
+
Priority:
|
|
21
|
+
1. AEGRO_FARMS env var (JSON string)
|
|
22
|
+
2. AEGRO_FARMS_FILE env var (path to JSON file)
|
|
23
|
+
3. ~/.config/aegro/credentials.json (interactive setup)
|
|
24
|
+
"""
|
|
25
|
+
env_farms = os.environ.get("AEGRO_FARMS")
|
|
26
|
+
if env_farms:
|
|
27
|
+
try:
|
|
28
|
+
farms = json.loads(env_farms)
|
|
29
|
+
except json.JSONDecodeError as exc:
|
|
30
|
+
_warn(f"AEGRO_FARMS contem JSON invalido: {exc}")
|
|
31
|
+
return {}
|
|
32
|
+
if not isinstance(farms, dict):
|
|
33
|
+
_warn(f"AEGRO_FARMS deve ser um objeto JSON, recebeu {type(farms).__name__}")
|
|
34
|
+
return {}
|
|
35
|
+
return farms
|
|
36
|
+
|
|
37
|
+
env_file = os.environ.get("AEGRO_FARMS_FILE")
|
|
38
|
+
if env_file:
|
|
39
|
+
try:
|
|
40
|
+
with open(env_file) as f:
|
|
41
|
+
farms = json.load(f)
|
|
42
|
+
except json.JSONDecodeError as exc:
|
|
43
|
+
_warn(f"AEGRO_FARMS_FILE ({env_file}) contem JSON invalido: {exc}")
|
|
44
|
+
return {}
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
_warn(f"Nao foi possivel ler AEGRO_FARMS_FILE ({env_file}): {exc}")
|
|
47
|
+
return {}
|
|
48
|
+
if not isinstance(farms, dict):
|
|
49
|
+
_warn(f"AEGRO_FARMS_FILE deve conter um objeto JSON, recebeu {type(farms).__name__}")
|
|
50
|
+
return {}
|
|
51
|
+
return farms
|
|
52
|
+
|
|
53
|
+
cred_file = CONFIG_DIR / CREDENTIALS_FILE_NAME
|
|
54
|
+
if cred_file.exists():
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(cred_file.read_text())
|
|
57
|
+
except json.JSONDecodeError as exc:
|
|
58
|
+
_warn(
|
|
59
|
+
f"Arquivo de credenciais corrompido ({cred_file}): {exc}. "
|
|
60
|
+
"Execute 'aegro auth login' novamente."
|
|
61
|
+
)
|
|
62
|
+
return {}
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
_warn(f"Nao foi possivel ler {cred_file}: {exc}")
|
|
65
|
+
return {}
|
|
66
|
+
farms = data.get("farms", {})
|
|
67
|
+
if not isinstance(farms, dict):
|
|
68
|
+
_warn(
|
|
69
|
+
f"Arquivo de credenciais corrompido ({cred_file}): 'farms' nao e um objeto. "
|
|
70
|
+
"Execute 'aegro auth login' novamente."
|
|
71
|
+
)
|
|
72
|
+
return {}
|
|
73
|
+
return farms
|
|
74
|
+
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_credentials(farms: dict[str, str]) -> None:
|
|
79
|
+
"""Save farm credentials atomically to ~/.config/aegro/credentials.json."""
|
|
80
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
cred_file = CONFIG_DIR / CREDENTIALS_FILE_NAME
|
|
82
|
+
data = {"farms": farms, "auth_method": "api-key"}
|
|
83
|
+
content = json.dumps(data, indent=2, ensure_ascii=False)
|
|
84
|
+
|
|
85
|
+
fd, tmp_path = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".tmp")
|
|
86
|
+
try:
|
|
87
|
+
with os.fdopen(fd, "w") as f:
|
|
88
|
+
f.write(content)
|
|
89
|
+
os.chmod(tmp_path, 0o600)
|
|
90
|
+
os.replace(tmp_path, str(cred_file))
|
|
91
|
+
except BaseException:
|
|
92
|
+
try:
|
|
93
|
+
os.unlink(tmp_path)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def remove_credentials() -> bool:
|
|
100
|
+
"""Remove stored credentials. Returns True if file existed."""
|
|
101
|
+
cred_file = CONFIG_DIR / CREDENTIALS_FILE_NAME
|
|
102
|
+
if cred_file.exists():
|
|
103
|
+
cred_file.unlink()
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_api_key(key: str) -> bool:
|
|
109
|
+
"""Basic validation that an API key is non-empty and non-whitespace."""
|
|
110
|
+
return bool(key and key.strip())
|
aegro/cli/_client.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from aegro.api_client import AegroClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SyncAegroClient:
|
|
9
|
+
"""Synchronous wrapper over async AegroClient for CLI use.
|
|
10
|
+
|
|
11
|
+
Creates a fresh AegroClient per call to avoid event loop reuse issues.
|
|
12
|
+
asyncio.run() creates and destroys an event loop each time, so reusing
|
|
13
|
+
an async httpx.AsyncClient across calls would cause 'attached to a
|
|
14
|
+
different loop' errors.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
base_url: str = "https://app.aegro.com.br",
|
|
20
|
+
timeout: float = 30.0,
|
|
21
|
+
max_retries: int = 3,
|
|
22
|
+
):
|
|
23
|
+
self._base_url = base_url
|
|
24
|
+
self._timeout = timeout
|
|
25
|
+
self._max_retries = max_retries
|
|
26
|
+
|
|
27
|
+
def _make_client(self) -> AegroClient:
|
|
28
|
+
return AegroClient(
|
|
29
|
+
base_url=self._base_url,
|
|
30
|
+
timeout=self._timeout,
|
|
31
|
+
max_retries=self._max_retries,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def get(self, path: str, *, api_key: str) -> dict | list | None:
|
|
35
|
+
async def _do() -> dict | list | None:
|
|
36
|
+
client = self._make_client()
|
|
37
|
+
try:
|
|
38
|
+
return await client.get(path, api_key=api_key)
|
|
39
|
+
finally:
|
|
40
|
+
await client.close()
|
|
41
|
+
|
|
42
|
+
return asyncio.run(_do())
|
|
43
|
+
|
|
44
|
+
def post(self, path: str, *, api_key: str, json_body: dict | None = None) -> dict | list | None:
|
|
45
|
+
async def _do() -> dict | list | None:
|
|
46
|
+
client = self._make_client()
|
|
47
|
+
try:
|
|
48
|
+
return await client.post(path, api_key=api_key, json_body=json_body)
|
|
49
|
+
finally:
|
|
50
|
+
await client.close()
|
|
51
|
+
|
|
52
|
+
return asyncio.run(_do())
|
|
53
|
+
|
|
54
|
+
def put(self, path: str, *, api_key: str, json_body: dict | None = None) -> dict | list | None:
|
|
55
|
+
async def _do() -> dict | list | None:
|
|
56
|
+
client = self._make_client()
|
|
57
|
+
try:
|
|
58
|
+
return await client.put(path, api_key=api_key, json_body=json_body)
|
|
59
|
+
finally:
|
|
60
|
+
await client.close()
|
|
61
|
+
|
|
62
|
+
return asyncio.run(_do())
|
|
63
|
+
|
|
64
|
+
def delete(self, path: str, *, api_key: str) -> dict | list | None:
|
|
65
|
+
async def _do() -> dict | list | None:
|
|
66
|
+
client = self._make_client()
|
|
67
|
+
try:
|
|
68
|
+
return await client.delete(path, api_key=api_key)
|
|
69
|
+
finally:
|
|
70
|
+
await client.close()
|
|
71
|
+
|
|
72
|
+
return asyncio.run(_do())
|
aegro/cli/_config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from aegro.config import Settings
|
|
10
|
+
from aegro.errors import FarmNotFoundError, NoFarmSelectedError
|
|
11
|
+
from aegro.cli._auth import load_credentials
|
|
12
|
+
from aegro.cli._client import SyncAegroClient
|
|
13
|
+
|
|
14
|
+
STATE_DIR = Path.home() / ".config" / "aegro"
|
|
15
|
+
|
|
16
|
+
_settings: Settings | None = None
|
|
17
|
+
_client: SyncAegroClient | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_settings() -> Settings:
|
|
21
|
+
"""Build Settings merging credentials from _auth.py.
|
|
22
|
+
|
|
23
|
+
Priority: env vars (AEGRO_FARMS, AEGRO_FARMS_FILE) > credentials.json.
|
|
24
|
+
The _auth.load_credentials() handles this priority order internally.
|
|
25
|
+
"""
|
|
26
|
+
global _settings
|
|
27
|
+
if _settings is None:
|
|
28
|
+
farms = load_credentials()
|
|
29
|
+
_settings = Settings(farms=farms) if farms else Settings()
|
|
30
|
+
return _settings
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_client() -> SyncAegroClient:
|
|
34
|
+
global _client
|
|
35
|
+
if _client is None:
|
|
36
|
+
s = get_settings()
|
|
37
|
+
_client = SyncAegroClient(
|
|
38
|
+
base_url=s.aegro_api_base_url,
|
|
39
|
+
timeout=s.api_timeout,
|
|
40
|
+
max_retries=s.api_max_retries,
|
|
41
|
+
)
|
|
42
|
+
return _client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_selected_farm() -> str | None:
|
|
46
|
+
state_file = STATE_DIR / "state.json"
|
|
47
|
+
if not state_file.exists():
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(state_file.read_text())
|
|
51
|
+
return data.get("selected_farm")
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
print(
|
|
54
|
+
f"aegro: aviso: state.json corrompido ({state_file}). "
|
|
55
|
+
"Execute 'aegro farms select <nome>' novamente.",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
return None
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
print(f"aegro: aviso: nao foi possivel ler {state_file}: {exc}", file=sys.stderr)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_selected_farm(farm_name: str) -> None:
|
|
65
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
state_file = STATE_DIR / "state.json"
|
|
67
|
+
data: dict = {}
|
|
68
|
+
if state_file.exists():
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(state_file.read_text())
|
|
71
|
+
except (json.JSONDecodeError, OSError):
|
|
72
|
+
data = {}
|
|
73
|
+
|
|
74
|
+
data["selected_farm"] = farm_name
|
|
75
|
+
content = json.dumps(data, indent=2)
|
|
76
|
+
|
|
77
|
+
# Atomic write
|
|
78
|
+
fd, tmp_path = tempfile.mkstemp(dir=STATE_DIR, suffix=".tmp")
|
|
79
|
+
try:
|
|
80
|
+
with os.fdopen(fd, "w") as f:
|
|
81
|
+
f.write(content)
|
|
82
|
+
os.replace(tmp_path, str(state_file))
|
|
83
|
+
except BaseException:
|
|
84
|
+
try:
|
|
85
|
+
os.unlink(tmp_path)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def require_farm(settings: Settings | None = None) -> tuple[str, str]:
|
|
92
|
+
"""Get selected farm name and API key, or raise."""
|
|
93
|
+
if settings is None:
|
|
94
|
+
settings = get_settings()
|
|
95
|
+
farm_name = get_selected_farm()
|
|
96
|
+
if farm_name is None:
|
|
97
|
+
raise NoFarmSelectedError()
|
|
98
|
+
api_key = settings.get_api_key(farm_name)
|
|
99
|
+
if api_key is None:
|
|
100
|
+
raise FarmNotFoundError(farm_name)
|
|
101
|
+
return farm_name, api_key
|
aegro/cli/_errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
from aegro.errors import (
|
|
8
|
+
AegroAPIError,
|
|
9
|
+
FarmNotFoundError,
|
|
10
|
+
InvalidKeyError,
|
|
11
|
+
NoFarmSelectedError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
EXIT_OK = 0
|
|
15
|
+
EXIT_ERROR = 1
|
|
16
|
+
EXIT_AUTH = 2
|
|
17
|
+
EXIT_NOT_FOUND = 3
|
|
18
|
+
EXIT_VALIDATION = 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle_error(exc: Exception) -> None:
|
|
22
|
+
"""Print structured JSON error to stderr and exit with semantic code."""
|
|
23
|
+
if isinstance(exc, NoFarmSelectedError):
|
|
24
|
+
_exit_json("NO_FARM_SELECTED", str(exc), EXIT_AUTH)
|
|
25
|
+
elif isinstance(exc, FarmNotFoundError):
|
|
26
|
+
_exit_json("FARM_NOT_FOUND", str(exc), EXIT_NOT_FOUND)
|
|
27
|
+
elif isinstance(exc, InvalidKeyError):
|
|
28
|
+
_exit_json("INVALID_KEY", str(exc), EXIT_VALIDATION)
|
|
29
|
+
elif isinstance(exc, AegroAPIError):
|
|
30
|
+
if exc.status_code in (401, 403):
|
|
31
|
+
_exit_json("AUTH_ERROR", exc.user_message, EXIT_AUTH, status=exc.status_code)
|
|
32
|
+
else:
|
|
33
|
+
_exit_json("API_ERROR", exc.user_message, EXIT_ERROR, status=exc.status_code)
|
|
34
|
+
else:
|
|
35
|
+
traceback.print_exc(file=sys.stderr)
|
|
36
|
+
_exit_json("UNEXPECTED", str(exc), EXIT_ERROR)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _exit_json(code: str, message: str, exit_code: int, *, status: int | None = None) -> None:
|
|
40
|
+
error: dict = {"code": code, "message": message}
|
|
41
|
+
if status is not None:
|
|
42
|
+
error["status"] = status
|
|
43
|
+
print(json.dumps({"error": error}), file=sys.stderr)
|
|
44
|
+
raise SystemExit(exit_code)
|
aegro/cli/_output.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OutputFormat(str, Enum):
|
|
10
|
+
json = "json"
|
|
11
|
+
table = "table"
|
|
12
|
+
csv = "csv"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cell_str(v: object) -> str:
|
|
16
|
+
"""Format a cell value for table/CSV display."""
|
|
17
|
+
if v is None:
|
|
18
|
+
return ""
|
|
19
|
+
if isinstance(v, (dict, list)):
|
|
20
|
+
return json.dumps(v, ensure_ascii=False)
|
|
21
|
+
return str(v)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_output(data: dict | list | str | None, fmt: OutputFormat) -> str:
|
|
25
|
+
"""Format data for CLI output. Returns a string ready to print."""
|
|
26
|
+
if fmt == OutputFormat.json:
|
|
27
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
28
|
+
|
|
29
|
+
if fmt == OutputFormat.csv:
|
|
30
|
+
return _format_csv(data)
|
|
31
|
+
|
|
32
|
+
if fmt == OutputFormat.table:
|
|
33
|
+
return _format_table(data)
|
|
34
|
+
|
|
35
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _format_csv(data: dict | list | str | None) -> str:
|
|
39
|
+
if data is None:
|
|
40
|
+
return ""
|
|
41
|
+
if isinstance(data, dict):
|
|
42
|
+
data = [data]
|
|
43
|
+
if isinstance(data, str):
|
|
44
|
+
return data
|
|
45
|
+
if not isinstance(data, list) or not data:
|
|
46
|
+
return ""
|
|
47
|
+
rows = data if isinstance(data[0], dict) else [{"value": r} for r in data]
|
|
48
|
+
# Collect ALL keys across all rows
|
|
49
|
+
all_keys = dict.fromkeys(k for row in rows for k in row.keys())
|
|
50
|
+
buf = io.StringIO()
|
|
51
|
+
writer = csv.DictWriter(buf, fieldnames=all_keys.keys(), extrasaction="ignore")
|
|
52
|
+
writer.writeheader()
|
|
53
|
+
for row in rows:
|
|
54
|
+
writer.writerow({k: _cell_str(v) for k, v in row.items()})
|
|
55
|
+
return buf.getvalue()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_table(data: dict | list | str | None) -> str:
|
|
59
|
+
from rich.console import Console
|
|
60
|
+
from rich.table import Table
|
|
61
|
+
|
|
62
|
+
console = Console(file=io.StringIO(), force_terminal=False)
|
|
63
|
+
|
|
64
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
65
|
+
all_keys = list(dict.fromkeys(k for row in data for k in row.keys()))
|
|
66
|
+
table = Table()
|
|
67
|
+
for key in all_keys:
|
|
68
|
+
table.add_column(key)
|
|
69
|
+
for row in data:
|
|
70
|
+
table.add_row(*[_cell_str(row.get(col)) for col in all_keys])
|
|
71
|
+
console.print(table)
|
|
72
|
+
elif isinstance(data, dict):
|
|
73
|
+
table = Table()
|
|
74
|
+
table.add_column("Field")
|
|
75
|
+
table.add_column("Value")
|
|
76
|
+
for k, v in data.items():
|
|
77
|
+
table.add_row(str(k), _cell_str(v))
|
|
78
|
+
console.print(table)
|
|
79
|
+
else:
|
|
80
|
+
console.print(str(data))
|
|
81
|
+
|
|
82
|
+
return console.file.getvalue()
|