geonode-scraper-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.
- geonode_scraper_cli/__init__.py +3 -0
- geonode_scraper_cli/commands/__init__.py +1 -0
- geonode_scraper_cli/commands/_common.py +42 -0
- geonode_scraper_cli/commands/batch.py +114 -0
- geonode_scraper_cli/commands/config.py +48 -0
- geonode_scraper_cli/commands/crawl.py +119 -0
- geonode_scraper_cli/commands/extract.py +78 -0
- geonode_scraper_cli/commands/jobs.py +77 -0
- geonode_scraper_cli/commands/map.py +75 -0
- geonode_scraper_cli/commands/stats.py +24 -0
- geonode_scraper_cli/commands/system.py +18 -0
- geonode_scraper_cli/config.py +104 -0
- geonode_scraper_cli/context.py +59 -0
- geonode_scraper_cli/errors.py +50 -0
- geonode_scraper_cli/invoke.py +56 -0
- geonode_scraper_cli/main.py +83 -0
- geonode_scraper_cli/output.py +9 -0
- geonode_scraper_cli/py.typed +0 -0
- geonode_scraper_cli/renderers/__init__.py +49 -0
- geonode_scraper_cli/renderers/tables.py +118 -0
- geonode_scraper_cli-0.1.0.dist-info/METADATA +159 -0
- geonode_scraper_cli-0.1.0.dist-info/RECORD +25 -0
- geonode_scraper_cli-0.1.0.dist-info/WHEEL +5 -0
- geonode_scraper_cli-0.1.0.dist-info/entry_points.txt +2 -0
- geonode_scraper_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Configuration resolution: flag > env > TOML file > default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try: # Python 3.11+
|
|
10
|
+
import tomllib
|
|
11
|
+
except ModuleNotFoundError: # Python 3.10
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
13
|
+
|
|
14
|
+
from geonode_scraper_tools_core import ScraperToolSettings
|
|
15
|
+
|
|
16
|
+
ENV_PREFIX = "GEONODE_SCRAPER_"
|
|
17
|
+
DEFAULT_PROFILE = "default"
|
|
18
|
+
DEFAULT_HOST = "http://localhost"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(Exception):
|
|
22
|
+
"""Raised when required configuration is missing or a config file is invalid."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_path() -> Path:
|
|
26
|
+
"""Location of the TOML config file, honouring ``XDG_CONFIG_HOME``."""
|
|
27
|
+
override = os.environ.get(f"{ENV_PREFIX}CONFIG")
|
|
28
|
+
if override:
|
|
29
|
+
return Path(override).expanduser()
|
|
30
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
31
|
+
return Path(base) / "geonode-scraper" / "config.toml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_profiles() -> dict[str, Any]:
|
|
35
|
+
path = config_path()
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except (tomllib.TOMLDecodeError, OSError) as exc:
|
|
41
|
+
raise ConfigError(f"Could not read config file {path}: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env(name: str) -> str | None:
|
|
45
|
+
return os.environ.get(f"{ENV_PREFIX}{name}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _as_bool(value: Any) -> bool:
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
return value
|
|
51
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_profile_name(profile: str | None) -> str:
|
|
55
|
+
return profile or _env("PROFILE") or DEFAULT_PROFILE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_settings(
|
|
59
|
+
*,
|
|
60
|
+
api_key: str | None = None,
|
|
61
|
+
host: str | None = None,
|
|
62
|
+
profile: str | None = None,
|
|
63
|
+
verify_ssl: bool | None = None,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
) -> ScraperToolSettings:
|
|
66
|
+
"""Merge CLI flags, environment, and the config-file profile into settings.
|
|
67
|
+
|
|
68
|
+
Precedence (highest first): explicit flag, environment variable, config file,
|
|
69
|
+
built-in default.
|
|
70
|
+
"""
|
|
71
|
+
profile_name = resolve_profile_name(profile)
|
|
72
|
+
profiles = load_profiles()
|
|
73
|
+
file_cfg = profiles.get(profile_name) or {}
|
|
74
|
+
if profile is not None and profile_name not in profiles:
|
|
75
|
+
raise ConfigError(f"Profile '{profile_name}' not found in {config_path()}.")
|
|
76
|
+
|
|
77
|
+
def pick(flag: Any, env_name: str, file_key: str, default: Any = None) -> Any:
|
|
78
|
+
if flag is not None:
|
|
79
|
+
return flag
|
|
80
|
+
env_value = _env(env_name)
|
|
81
|
+
if env_value is not None:
|
|
82
|
+
return env_value
|
|
83
|
+
if file_key in file_cfg:
|
|
84
|
+
return file_cfg[file_key]
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
resolved_host = pick(host, "HOST", "host", DEFAULT_HOST)
|
|
88
|
+
resolved_key = pick(api_key, "API_KEY", "api_key")
|
|
89
|
+
resolved_verify = pick(verify_ssl, "VERIFY_SSL", "verify_ssl", True)
|
|
90
|
+
resolved_timeout = pick(timeout, "TIMEOUT", "timeout")
|
|
91
|
+
|
|
92
|
+
if not resolved_key:
|
|
93
|
+
raise ConfigError(
|
|
94
|
+
"No API key configured. Pass --api-key, set "
|
|
95
|
+
f"{ENV_PREFIX}API_KEY, or add 'api_key' to your config profile "
|
|
96
|
+
f"({config_path()})."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return ScraperToolSettings(
|
|
100
|
+
host=str(resolved_host),
|
|
101
|
+
api_key=str(resolved_key),
|
|
102
|
+
verify_ssl=_as_bool(resolved_verify),
|
|
103
|
+
request_timeout=float(resolved_timeout) if resolved_timeout is not None else None,
|
|
104
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Per-invocation CLI state and lazy service construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from geonode_scraper_tools_core import ScraperToolService, ScraperToolSettings
|
|
9
|
+
|
|
10
|
+
from .config import ConfigError, resolve_settings
|
|
11
|
+
from .errors import ExitCode
|
|
12
|
+
from .output import OutputFormat
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CLIContext:
|
|
17
|
+
"""Holds resolved global options; builds the service on demand.
|
|
18
|
+
|
|
19
|
+
Credentials are only required when a command actually calls the API, so
|
|
20
|
+
``--help``/``--version`` and the ``config`` commands never fail on a missing
|
|
21
|
+
API key.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
api_key: str | None
|
|
25
|
+
host: str | None
|
|
26
|
+
profile: str | None
|
|
27
|
+
verify_ssl: bool | None
|
|
28
|
+
timeout: float | None
|
|
29
|
+
output: OutputFormat
|
|
30
|
+
verbose: bool
|
|
31
|
+
|
|
32
|
+
_settings: ScraperToolSettings | None = None
|
|
33
|
+
_service: ScraperToolService | None = None
|
|
34
|
+
|
|
35
|
+
def settings(self) -> ScraperToolSettings:
|
|
36
|
+
if self._settings is None:
|
|
37
|
+
try:
|
|
38
|
+
self._settings = resolve_settings(
|
|
39
|
+
api_key=self.api_key,
|
|
40
|
+
host=self.host,
|
|
41
|
+
profile=self.profile,
|
|
42
|
+
verify_ssl=self.verify_ssl,
|
|
43
|
+
timeout=self.timeout,
|
|
44
|
+
)
|
|
45
|
+
except ConfigError as exc:
|
|
46
|
+
typer.echo(str(exc), err=True)
|
|
47
|
+
raise typer.Exit(int(ExitCode.USAGE)) from exc
|
|
48
|
+
return self._settings
|
|
49
|
+
|
|
50
|
+
def service(self) -> ScraperToolService:
|
|
51
|
+
if self._service is None:
|
|
52
|
+
self._service = ScraperToolService(self.settings())
|
|
53
|
+
return self._service
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_cli_context(ctx: typer.Context) -> CLIContext:
|
|
57
|
+
obj = ctx.obj
|
|
58
|
+
assert isinstance(obj, CLIContext) # set by the root callback
|
|
59
|
+
return obj
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Map service error envelopes and exceptions to user-facing messages + exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExitCode(IntEnum):
|
|
10
|
+
OK = 0
|
|
11
|
+
GENERIC = 1
|
|
12
|
+
USAGE = 2
|
|
13
|
+
AUTH = 4
|
|
14
|
+
NOT_FOUND = 5
|
|
15
|
+
VALIDATION = 6
|
|
16
|
+
NETWORK = 7
|
|
17
|
+
TIMEOUT = 8
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def exit_code_for_error(error: Mapping[str, Any]) -> ExitCode:
|
|
21
|
+
"""Choose a POSIX exit code from a service error dict."""
|
|
22
|
+
code = error.get("code")
|
|
23
|
+
if code == "POLL_TIMEOUT":
|
|
24
|
+
return ExitCode.TIMEOUT
|
|
25
|
+
|
|
26
|
+
status = error.get("status")
|
|
27
|
+
if status in (401, 403):
|
|
28
|
+
return ExitCode.AUTH
|
|
29
|
+
if status == 404:
|
|
30
|
+
return ExitCode.NOT_FOUND
|
|
31
|
+
if status == 422:
|
|
32
|
+
return ExitCode.VALIDATION
|
|
33
|
+
if status is None:
|
|
34
|
+
# No HTTP status reached the server: local/connection problem.
|
|
35
|
+
return ExitCode.NETWORK
|
|
36
|
+
return ExitCode.GENERIC
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_error(error: Mapping[str, Any]) -> str:
|
|
40
|
+
"""Build a single-line human message from a service error dict."""
|
|
41
|
+
message = error.get("message") or error.get("reason") or "Request failed."
|
|
42
|
+
status = error.get("status")
|
|
43
|
+
code = error.get("code")
|
|
44
|
+
prefix_parts = []
|
|
45
|
+
if status is not None:
|
|
46
|
+
prefix_parts.append(f"HTTP {status}")
|
|
47
|
+
if code:
|
|
48
|
+
prefix_parts.append(str(code))
|
|
49
|
+
prefix = f"[{' '.join(prefix_parts)}] " if prefix_parts else ""
|
|
50
|
+
return f"{prefix}{message}"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared command plumbing: validate flags via tools-core schemas, call, emit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, NoReturn
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from .context import get_cli_context
|
|
12
|
+
from .errors import ExitCode
|
|
13
|
+
from .renderers import emit
|
|
14
|
+
|
|
15
|
+
_stderr = Console(stderr=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validated(schema_cls: type[BaseModel], /, **kwargs: Any) -> dict[str, Any]:
|
|
19
|
+
"""Validate user-supplied flags through a tools-core input schema.
|
|
20
|
+
|
|
21
|
+
Only pass keys the user actually provided; the schema fills defaults. Keeps
|
|
22
|
+
validation rules (country code, ranges, enums) defined once in tools-core.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
model = schema_cls(**kwargs)
|
|
26
|
+
except ValidationError as exc:
|
|
27
|
+
_stderr.print("[red]Invalid arguments:[/red]")
|
|
28
|
+
for err in exc.errors():
|
|
29
|
+
loc = ".".join(str(part) for part in err["loc"]) or "(value)"
|
|
30
|
+
_stderr.print(f" {loc}: {err['msg']}")
|
|
31
|
+
raise typer.Exit(int(ExitCode.USAGE)) from exc
|
|
32
|
+
return model.model_dump()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def call(ctx: typer.Context, method_name: str, payload: dict[str, Any]) -> NoReturn:
|
|
36
|
+
"""Invoke a service method and emit the result envelope.
|
|
37
|
+
|
|
38
|
+
Unexpected (non-``ApiException``) failures — e.g. connection errors — are
|
|
39
|
+
wrapped into an error envelope so output stays uniform; ``--verbose`` lets
|
|
40
|
+
the original traceback through.
|
|
41
|
+
"""
|
|
42
|
+
cli = get_cli_context(ctx)
|
|
43
|
+
service = cli.service()
|
|
44
|
+
try:
|
|
45
|
+
result = getattr(service, method_name)(**payload)
|
|
46
|
+
except typer.Exit:
|
|
47
|
+
raise
|
|
48
|
+
except Exception as exc: # noqa: BLE001 - normalise to an error envelope
|
|
49
|
+
if cli.verbose:
|
|
50
|
+
raise
|
|
51
|
+
result = {
|
|
52
|
+
"ok": False,
|
|
53
|
+
"operation": method_name,
|
|
54
|
+
"error": {"status": None, "code": "CLIENT_ERROR", "message": str(exc)},
|
|
55
|
+
}
|
|
56
|
+
emit(cli.output, result)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""gscraper — command-line interface for the Geonode Scraper API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .commands.batch import batch_app
|
|
11
|
+
from .commands.config import config_app
|
|
12
|
+
from .commands.crawl import crawl_app
|
|
13
|
+
from .commands.extract import extract
|
|
14
|
+
from .commands.jobs import jobs_app
|
|
15
|
+
from .commands.map import map_app
|
|
16
|
+
from .commands.stats import stats
|
|
17
|
+
from .commands.system import health
|
|
18
|
+
from .context import CLIContext
|
|
19
|
+
from .output import OutputFormat
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="gscraper",
|
|
23
|
+
help="Command-line interface for the Geonode Scraper API.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
typer.echo(f"gscraper {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_output(as_json: bool, as_yaml: bool) -> OutputFormat:
|
|
36
|
+
if as_json and as_yaml:
|
|
37
|
+
raise typer.BadParameter("Use only one of --json or --yaml.")
|
|
38
|
+
if as_json:
|
|
39
|
+
return OutputFormat.JSON
|
|
40
|
+
if as_yaml:
|
|
41
|
+
return OutputFormat.YAML
|
|
42
|
+
return OutputFormat.TABLE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def main_callback(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", help="API key (prefer env/config over the CLI flag)."),
|
|
49
|
+
host: Optional[str] = typer.Option(None, "--host", help="API base URL."),
|
|
50
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="Config profile to use."),
|
|
51
|
+
as_json: bool = typer.Option(False, "--json", help="Output the raw JSON envelope."),
|
|
52
|
+
as_yaml: bool = typer.Option(False, "--yaml", help="Output the raw YAML envelope."),
|
|
53
|
+
verify_ssl: Optional[bool] = typer.Option(None, "--verify-ssl/--no-verify-ssl", help="Verify TLS certificates."),
|
|
54
|
+
timeout: Optional[float] = typer.Option(None, "--timeout", help="Request timeout in seconds."),
|
|
55
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show full tracebacks on unexpected errors."),
|
|
56
|
+
_version: bool = typer.Option(
|
|
57
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Resolve global options into the shared CLI context."""
|
|
61
|
+
ctx.obj = CLIContext(
|
|
62
|
+
api_key=api_key,
|
|
63
|
+
host=host,
|
|
64
|
+
profile=profile,
|
|
65
|
+
verify_ssl=verify_ssl,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
output=_resolve_output(as_json, as_yaml),
|
|
68
|
+
verbose=verbose,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app.command("extract")(extract)
|
|
73
|
+
app.command("stats")(stats)
|
|
74
|
+
app.command("health")(health)
|
|
75
|
+
app.add_typer(jobs_app, name="jobs")
|
|
76
|
+
app.add_typer(batch_app, name="batch")
|
|
77
|
+
app.add_typer(crawl_app, name="crawl")
|
|
78
|
+
app.add_typer(map_app, name="map")
|
|
79
|
+
app.add_typer(config_app, name="config")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Output dispatch: render a service result envelope and exit with a code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Mapping, NoReturn
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import yaml
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from ..errors import ExitCode, exit_code_for_error, format_error
|
|
13
|
+
from ..output import OutputFormat
|
|
14
|
+
from .tables import render_human
|
|
15
|
+
|
|
16
|
+
# rich auto-disables styling when stdout is not a TTY and honours NO_COLOR.
|
|
17
|
+
_stdout = Console()
|
|
18
|
+
_stderr = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def console() -> Console:
|
|
22
|
+
return _stdout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _to_jsonable(value: Any) -> Any:
|
|
26
|
+
return json.loads(json.dumps(value, default=str))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def emit(output: OutputFormat, result: Mapping[str, Any]) -> NoReturn:
|
|
30
|
+
"""Render a service envelope (``{ok, operation, result|error, ...}``) and exit.
|
|
31
|
+
|
|
32
|
+
The JSON/YAML formats print the full envelope so both success and error are
|
|
33
|
+
machine-parseable; the table format prints a human summary or a friendly
|
|
34
|
+
error line on stderr.
|
|
35
|
+
"""
|
|
36
|
+
ok = bool(result.get("ok"))
|
|
37
|
+
code = ExitCode.OK if ok else exit_code_for_error(result.get("error") or {})
|
|
38
|
+
|
|
39
|
+
if output is OutputFormat.JSON:
|
|
40
|
+
_stdout.print_json(json.dumps(_to_jsonable(result)))
|
|
41
|
+
elif output is OutputFormat.YAML:
|
|
42
|
+
_stdout.print(yaml.safe_dump(_to_jsonable(result), sort_keys=False).rstrip())
|
|
43
|
+
else:
|
|
44
|
+
if ok:
|
|
45
|
+
render_human(result, _stdout)
|
|
46
|
+
else:
|
|
47
|
+
_stderr.print(f"[red]{format_error(result.get('error') or {})}[/red]")
|
|
48
|
+
|
|
49
|
+
raise typer.Exit(int(code))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Human-friendly rendering of service result payloads.
|
|
2
|
+
|
|
3
|
+
Designed to be defensive: the service returns plain dicts whose shape tracks the
|
|
4
|
+
OpenAPI spec and can change between versions. Renderers use safe access and a
|
|
5
|
+
generic fallback, and ``render_human`` ultimately falls back to pretty JSON for
|
|
6
|
+
any shape it cannot format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
# Long text fields (markdown/html/raw content) are summarised by length rather
|
|
17
|
+
# than dumped into the terminal.
|
|
18
|
+
_LONG_TEXT_THRESHOLD = 200
|
|
19
|
+
|
|
20
|
+
# Result keys that commonly hold a list of records worth rendering as a table.
|
|
21
|
+
_COLLECTION_KEYS = ("jobs", "items", "links", "results", "data")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_scalar(value: Any) -> bool:
|
|
25
|
+
return value is None or isinstance(value, (str, int, float, bool))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _summarize_scalar(value: Any) -> str:
|
|
29
|
+
if value is None:
|
|
30
|
+
return "-"
|
|
31
|
+
if isinstance(value, str) and len(value) > _LONG_TEXT_THRESHOLD:
|
|
32
|
+
return f"<{len(value)} chars>"
|
|
33
|
+
return str(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _kv_table(data: Mapping[str, Any], console: Console, *, title: str | None = None) -> None:
|
|
37
|
+
table = Table(show_header=False, box=None, title=title, title_justify="left", pad_edge=False)
|
|
38
|
+
table.add_column("field", style="bold cyan", no_wrap=True)
|
|
39
|
+
table.add_column("value", overflow="fold")
|
|
40
|
+
for key, value in data.items():
|
|
41
|
+
if _is_scalar(value):
|
|
42
|
+
table.add_row(str(key), _summarize_scalar(value))
|
|
43
|
+
elif isinstance(value, Mapping):
|
|
44
|
+
table.add_row(str(key), _summarize_mapping(value))
|
|
45
|
+
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
46
|
+
table.add_row(str(key), f"<{len(value)} item(s)>")
|
|
47
|
+
else:
|
|
48
|
+
table.add_row(str(key), str(value))
|
|
49
|
+
console.print(table)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _summarize_mapping(data: Mapping[str, Any]) -> str:
|
|
53
|
+
parts = []
|
|
54
|
+
for key, value in data.items():
|
|
55
|
+
if _is_scalar(value):
|
|
56
|
+
parts.append(f"{key}={_summarize_scalar(value)}")
|
|
57
|
+
else:
|
|
58
|
+
parts.append(f"{key}=…")
|
|
59
|
+
return ", ".join(parts) if parts else "{}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _records_table(records: Sequence[Mapping[str, Any]], console: Console, *, title: str | None = None) -> None:
|
|
63
|
+
# Union of keys across records, preserving first-seen order; scalar columns only.
|
|
64
|
+
columns: list[str] = []
|
|
65
|
+
for record in records:
|
|
66
|
+
if not isinstance(record, Mapping):
|
|
67
|
+
# Heterogeneous / non-dict list: bail to the generic path.
|
|
68
|
+
console.print_json(data=list(records))
|
|
69
|
+
return
|
|
70
|
+
for key, value in record.items():
|
|
71
|
+
if key not in columns and _is_scalar(value):
|
|
72
|
+
columns.append(key)
|
|
73
|
+
|
|
74
|
+
if not columns:
|
|
75
|
+
console.print_json(data=list(records))
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
table = Table(title=title, title_justify="left", header_style="bold cyan")
|
|
79
|
+
for column in columns:
|
|
80
|
+
table.add_column(column, overflow="fold")
|
|
81
|
+
for record in records:
|
|
82
|
+
table.add_row(*[_summarize_scalar(record.get(column)) for column in columns])
|
|
83
|
+
console.print(table)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_generic(data: Any, console: Console) -> None:
|
|
87
|
+
if isinstance(data, Mapping):
|
|
88
|
+
scalars = {k: v for k, v in data.items() if _is_scalar(v) or isinstance(v, Mapping)}
|
|
89
|
+
if scalars:
|
|
90
|
+
_kv_table(scalars, console)
|
|
91
|
+
# Render the first meaningful collection field as its own table.
|
|
92
|
+
for key, value in data.items():
|
|
93
|
+
if (
|
|
94
|
+
isinstance(value, Sequence)
|
|
95
|
+
and not isinstance(value, (str, bytes, bytearray))
|
|
96
|
+
and value
|
|
97
|
+
and (key in _COLLECTION_KEYS or all(isinstance(item, Mapping) for item in value))
|
|
98
|
+
):
|
|
99
|
+
console.print()
|
|
100
|
+
_records_table(value, console, title=str(key))
|
|
101
|
+
break
|
|
102
|
+
elif isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)):
|
|
103
|
+
_records_table(data, console)
|
|
104
|
+
else:
|
|
105
|
+
console.print(str(data))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_human(result: Mapping[str, Any], console: Console) -> None:
|
|
109
|
+
"""Render a successful service envelope's ``result`` for human consumption."""
|
|
110
|
+
data = result.get("result")
|
|
111
|
+
if data is None:
|
|
112
|
+
operation = result.get("operation", "operation")
|
|
113
|
+
console.print(f"[green]{operation}: ok[/green]")
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
_render_generic(data, console)
|
|
117
|
+
except Exception: # noqa: BLE001 - unfamiliar shape: never crash on rendering
|
|
118
|
+
console.print_json(data=data)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geonode-scraper-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for the Geonode Scraper API
|
|
5
|
+
Author: Geonode Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: geonode-scraper-tools-core>=0.3.1
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Requires-Dist: rich>=13.0
|
|
16
|
+
Requires-Dist: PyYAML>=6.0
|
|
17
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.12.11; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# Geonode Scraper CLI
|
|
24
|
+
|
|
25
|
+
`gscraper` is the command-line interface for the Geonode Scraper API. It is a thin
|
|
26
|
+
presentation layer over [`geonode-scraper-tools-core`](../python-agent-tools-core):
|
|
27
|
+
commands parse flags, resolve configuration, call a stable service method, and
|
|
28
|
+
render the result. All domain logic (validation, polling, retries) lives in the
|
|
29
|
+
service layer, not in the CLI.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Python 3.10+
|
|
34
|
+
- Works on **Linux**, **macOS**, and **Windows**
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
**Recommended — install as a standalone tool with [pipx](https://pipx.pypa.io):**
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pipx install geonode-scraper-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`pipx` installs `gscraper` into its own isolated virtual environment and puts it
|
|
45
|
+
on your `PATH`, so it never conflicts with other Python projects. This is the
|
|
46
|
+
preferred way to install CLI tools globally.
|
|
47
|
+
|
|
48
|
+
**Alternative — install with pip:**
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pip install geonode-scraper-cli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> **Windows note:** on Windows the `gscraper` command is placed in the Python
|
|
55
|
+
> Scripts folder (e.g. `%APPDATA%\Python\Python3xx\Scripts`). If the command is
|
|
56
|
+
> not found after installation, add that folder to your `PATH`, or use
|
|
57
|
+
> `python -m geonode_scraper_cli` as a fallback. `pipx` handles this
|
|
58
|
+
> automatically and is the simpler choice on Windows.
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
Configuration is resolved with the following precedence (highest first):
|
|
63
|
+
|
|
64
|
+
1. Command-line flags (`--api-key`, `--host`, ...)
|
|
65
|
+
2. Environment variables (`GEONODE_SCRAPER_API_KEY`, `GEONODE_SCRAPER_HOST`,
|
|
66
|
+
`GEONODE_SCRAPER_VERIFY_SSL`, `GEONODE_SCRAPER_TIMEOUT`, `GEONODE_SCRAPER_PROFILE`)
|
|
67
|
+
3. A TOML config file at `~/.config/geonode-scraper/config.toml`
|
|
68
|
+
4. Built-in defaults
|
|
69
|
+
|
|
70
|
+
Prefer environment variables or the config file for your API key — passing
|
|
71
|
+
`--api-key` on the command line can leak it into your shell history.
|
|
72
|
+
|
|
73
|
+
Example `~/.config/geonode-scraper/config.toml`:
|
|
74
|
+
|
|
75
|
+
```toml
|
|
76
|
+
[default]
|
|
77
|
+
host = "https://api.example.com"
|
|
78
|
+
api_key = "your-api-key"
|
|
79
|
+
verify_ssl = true
|
|
80
|
+
|
|
81
|
+
[staging]
|
|
82
|
+
host = "https://staging.example.com"
|
|
83
|
+
api_key = "your-staging-key"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Select a non-default profile with `--profile staging` or
|
|
87
|
+
`GEONODE_SCRAPER_PROFILE=staging`. Inspect the active configuration with:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
gscraper config path # print the config file location
|
|
91
|
+
gscraper config show # show profiles (API keys masked)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Output
|
|
95
|
+
|
|
96
|
+
Commands print a human-readable summary by default. Use `--json` or `--yaml`
|
|
97
|
+
to print the raw result envelope for scripting. These flags can appear either
|
|
98
|
+
before the subcommand (global position) or after it (per-command position) —
|
|
99
|
+
both work:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
gscraper extract https://example.com --json | jq -r .result.data.markdown
|
|
103
|
+
gscraper --json extract https://example.com | jq -r .result.data.markdown
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The JSON/YAML envelope has the shape `{ "ok": bool, "operation": str, "result": {...} }`
|
|
107
|
+
on success, or `{ "ok": false, "operation": str, "error": {...} }` on failure.
|
|
108
|
+
|
|
109
|
+
## Commands
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
gscraper extract URL [--format markdown|html] [--render-js] [--async] \
|
|
113
|
+
[--proxy-country US] [--proxy-type residential] \
|
|
114
|
+
[--header "K: V"] [--output out.md]
|
|
115
|
+
|
|
116
|
+
gscraper jobs get JOB_ID
|
|
117
|
+
gscraper jobs list [--status completed] [--url ...] [--page N]
|
|
118
|
+
gscraper jobs wait JOB_ID [--timeout S] [--interval S]
|
|
119
|
+
|
|
120
|
+
gscraper batch create URL [URL ...] [--format markdown]
|
|
121
|
+
gscraper batch status JOB_ID
|
|
122
|
+
gscraper batch wait JOB_ID [--timeout S] [--interval S]
|
|
123
|
+
gscraper batch list [--status ...]
|
|
124
|
+
gscraper batch cancel JOB_ID
|
|
125
|
+
|
|
126
|
+
gscraper crawl create URL [--depth 2] [--limit 50] [--include-subdomains]
|
|
127
|
+
gscraper crawl status JOB_ID
|
|
128
|
+
gscraper crawl wait JOB_ID
|
|
129
|
+
gscraper crawl list [--url ...]
|
|
130
|
+
gscraper crawl cancel JOB_ID
|
|
131
|
+
|
|
132
|
+
gscraper map run URL [--search term] [--no-subdomains] # primary action
|
|
133
|
+
gscraper map jobs list # inspect past map jobs
|
|
134
|
+
gscraper map jobs get JOB_ID
|
|
135
|
+
|
|
136
|
+
gscraper stats [--start-date ISO] [--end-date ISO]
|
|
137
|
+
gscraper health
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Run `gscraper --help` or `gscraper <command> --help` for full details.
|
|
141
|
+
|
|
142
|
+
## Exit codes
|
|
143
|
+
|
|
144
|
+
| Code | Meaning |
|
|
145
|
+
| ---- | ------- |
|
|
146
|
+
| 0 | Success |
|
|
147
|
+
| 1 | Generic error |
|
|
148
|
+
| 2 | Usage / invalid arguments |
|
|
149
|
+
| 4 | Authentication / authorization (401, 403) |
|
|
150
|
+
| 5 | Not found (404) |
|
|
151
|
+
| 6 | Validation error (422) |
|
|
152
|
+
| 7 | Network / connection error |
|
|
153
|
+
| 8 | Polling timeout (`wait` commands) |
|
|
154
|
+
|
|
155
|
+
## Shell completion
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
gscraper --install-completion
|
|
159
|
+
```
|