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 ADDED
@@ -0,0 +1,4 @@
1
+ try:
2
+ from aegro._version import __version__
3
+ except ImportError:
4
+ __version__ = "0.0.0+unknown"
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()