geodeploy 1.3.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.
@@ -0,0 +1,154 @@
1
+ """`geodeploy upload` — one command for every kind of file, and any number of them.
2
+
3
+ The route (through the API, direct-to-storage, chunked, CSV, raster) is worked out per file by
4
+ `geodeploy.uploads.plan`; `--dry-run` prints those decisions without moving a byte, which is the
5
+ honest way to find out that a 300 MB GeoPackage is going to become a GeoParquet layer rather than
6
+ a PostGIS table.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Dict, List
12
+
13
+ from ...errors import GeoDeployError
14
+ from ...uploads import LARGE_UPLOAD_THRESHOLD
15
+ from ..main import add_command
16
+ from ..output import EXIT_GENERIC, EXIT_OK, human_size
17
+ from ._common import confirm # noqa: F401 (kept for symmetry with other command modules)
18
+
19
+
20
+ def register(subparsers) -> None:
21
+ parser = add_command(
22
+ subparsers, "upload", cmd_upload,
23
+ "upload one or more files and register them as layers",
24
+ epilog="""\
25
+ examples:
26
+ geodeploy upload roads.gpkg one file
27
+ geodeploy upload *.gpkg *.tif --wait a whole directory's worth, waiting for ingest
28
+ geodeploy upload sites.csv --x lon --y lat CSV points (columns guessed if omitted)
29
+ geodeploy upload plots.csv --wkt geometry CSV with WKT geometry of any type
30
+ geodeploy upload big.parquet --name "Parcels" GeoParquet, direct to storage, chunked
31
+ geodeploy upload data/*.tif --dry-run what would happen, without doing it
32
+
33
+ Files at or over {threshold} bypass the API and upload straight to object storage in parts —
34
+ that is what makes a multi-gigabyte upload survive a proxy in front of the instance.
35
+ """.format(threshold=human_size(LARGE_UPLOAD_THRESHOLD)))
36
+
37
+ parser.add_argument("files", nargs="+", help="files to upload")
38
+ parser.add_argument("--type", dest="layer_type", choices=["vector", "raster"],
39
+ help="force the layer type (default: from the extension)")
40
+ parser.add_argument("--name", help="layer name (only sensible with a single file)")
41
+ parser.add_argument("--wait", action="store_true",
42
+ help="wait for ingest to finish, and fail if it does not")
43
+ parser.add_argument("--dry-run", action="store_true",
44
+ help="show the route each file would take, and stop")
45
+ parser.add_argument("--concurrency", type=int, default=1,
46
+ help="files uploaded at once (default 1; each large file already uses "
47
+ "four parallel parts)")
48
+ parser.add_argument("--stop-on-error", action="store_true",
49
+ help="stop at the first failure instead of continuing")
50
+ parser.add_argument("--public", action="store_true",
51
+ help="share each layer publicly once it is ready (STAC + OGC + raw asset)")
52
+
53
+ csv_group = parser.add_argument_group("CSV geometry")
54
+ csv_group.add_argument("--x", dest="x_column", help="longitude/easting column")
55
+ csv_group.add_argument("--y", dest="y_column", help="latitude/northing column")
56
+ csv_group.add_argument("--wkt", dest="wkt_column", help="WKT geometry column (any geometry type)")
57
+ csv_group.add_argument("--srid", type=int, default=4326, help="CRS of the coordinates (default 4326)")
58
+ csv_group.add_argument("--delimiter", choices=["comma", "semicolon", "tab", "pipe", "space"],
59
+ help="field delimiter (default: sniffed from the file)")
60
+ csv_group.add_argument("--no-guess", action="store_true",
61
+ help="do not guess geometry columns — fail instead")
62
+
63
+
64
+ def cmd_upload(ctx, args) -> int:
65
+ client = ctx.client()
66
+ out = ctx.out
67
+
68
+ if args.name and len(args.files) > 1:
69
+ out.error("--name applies to a single file; upload them one at a time to name each.")
70
+ return EXIT_GENERIC
71
+
72
+ plans = []
73
+ for path in args.files:
74
+ plans.append(client.uploads.plan(
75
+ path, layer_type=args.layer_type, name=args.name, x_column=args.x_column,
76
+ y_column=args.y_column, wkt_column=args.wkt_column, srid=args.srid,
77
+ delimiter=args.delimiter, guess_csv=not args.no_guess))
78
+
79
+ for plan in plans:
80
+ if plan.csv_opts and plan.csv_opts.get("guessed"):
81
+ geometry = (("WKT column {0}".format(plan.csv_opts["wkt_column"]))
82
+ if plan.csv_opts.get("wkt_column")
83
+ else "x={0}, y={1}".format(plan.csv_opts.get("x_column"),
84
+ plan.csv_opts.get("y_column")))
85
+ out.warn("{0}: guessed geometry from the header ({1}). Pass --x/--y or --wkt to be "
86
+ "explicit.".format(os.path.basename(plan.path), geometry))
87
+
88
+ if args.dry_run:
89
+ ctx.out.render([dict(p.as_dict(), size_h=human_size(p.size)) for p in plans],
90
+ ["path", "layer_type", "route", "name", "size_h", "chunked", "reason"])
91
+ return EXIT_OK
92
+
93
+ results = [] # type: List[Any]
94
+ failures = 0
95
+ for plan in plans:
96
+ progress = out.progress(os.path.basename(plan.path), plan.size)
97
+ out.info("{0} → {1} ({2}, {3})".format(os.path.basename(plan.path), plan.name,
98
+ plan.route, human_size(plan.size)))
99
+ try:
100
+ result = client.uploads.upload(
101
+ plan.path, plan=plan, wait=False,
102
+ on_progress=lambda done, total: progress.update(done, total))
103
+ progress.finish()
104
+ out.info(" queued as layer {0} (job {1})".format(result.layer_id, result.job_id))
105
+ results.append(result)
106
+ except GeoDeployError as exc:
107
+ progress.finish()
108
+ failures += 1
109
+ out.error("{0}: {1}".format(os.path.basename(plan.path), exc))
110
+ if args.stop_on_error:
111
+ break
112
+
113
+ if args.wait:
114
+ for result in results:
115
+ failures += _wait_for(ctx, result)
116
+
117
+ if args.public:
118
+ for result in results:
119
+ if result.final is None or (result.final or {}).get("status") in ("ready", "completed"):
120
+ try:
121
+ client.layers.api(result.plan.layer_type).share(result.layer_id,
122
+ visibility="public")
123
+ out.info(" {0} is now public.".format(result.plan.name))
124
+ except GeoDeployError as exc:
125
+ out.warn("Could not share {0}: {1}".format(result.plan.name, exc))
126
+
127
+ payload = [r.as_dict() for r in results]
128
+ if out.json_mode:
129
+ out.json({"ok": failures == 0, "uploaded": payload, "failed": failures})
130
+ else:
131
+ out.table(payload, ["file", "name", "layer_type", "layer_id", "job_id"]) if payload else None
132
+ if not failures:
133
+ out.success("{0} file(s) uploaded.{1}".format(
134
+ len(results), "" if args.wait else " Ingest continues in the background — "
135
+ "`geodeploy layers list` shows progress."))
136
+ return EXIT_GENERIC if failures else EXIT_OK
137
+
138
+
139
+ def _wait_for(ctx, result) -> int:
140
+ """Follow one ingest job to the end. Returns 1 if it failed, 0 otherwise."""
141
+ out = ctx.out
142
+ label = os.path.basename(result.plan.path)
143
+
144
+ def on_progress(status: Dict[str, Any]) -> None:
145
+ out.info(" {0}: {1:3d}% {2}".format(label, status.get("progress") or 0,
146
+ status.get("current_step") or status.get("status")))
147
+ try:
148
+ result.final = ctx.client().jobs.wait(result.job_id, result.plan.layer_type,
149
+ on_progress=on_progress)
150
+ out.success("{0} ready (layer {1}).".format(label, result.layer_id))
151
+ return 0
152
+ except GeoDeployError as exc:
153
+ out.error("{0}: {1}".format(label, exc))
154
+ return 1
geodeploy/cli/main.py ADDED
@@ -0,0 +1,263 @@
1
+ """`geodeploy` — the entry point: global options, dispatch, and the one place errors become exits.
2
+
3
+ Argparse rather than a framework, for the no-dependency rule. The shape is conventional so it needs
4
+ no learning: `geodeploy <group> <command> [args]`, `--json` anywhere, `-h` at every level.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from typing import Any, List, Optional
11
+
12
+ from .. import __version__
13
+ from ..config import Config, resolve
14
+ from ..errors import (APIError, AuthError, ConfigError, GeoDeployError, PermissionError_,
15
+ ServerError, TransportError, ValidationError)
16
+ from ..jobs import JobFailed, JobTimeout
17
+ from . import output
18
+ from .output import (EXIT_AUTH, EXIT_GENERIC, EXIT_NETWORK, EXIT_OK, EXIT_SERVER, EXIT_USAGE,
19
+ Formatter)
20
+
21
+ EPILOG = """\
22
+ examples:
23
+ geodeploy login https://geodeploy.example.org log in and remember the instance
24
+ geodeploy upload roads.gpkg sites.csv --wait upload several files and wait for ingest
25
+ geodeploy layers list --type vector what is on the instance
26
+ geodeploy portals add-layer 3 roads --color '#e11d48' --marker star
27
+ geodeploy portals style 3 roads --color-field pop --classify quantile --classes 5
28
+ geodeploy portals publish 3 make the edits live
29
+
30
+ Every command takes --json for machine-readable output. Exit codes: 0 ok, 1 failed, 2 bad usage,
31
+ 3 authentication, 4 network, 5 server error.
32
+ """
33
+
34
+
35
+ class Context(object):
36
+ """Everything a command handler needs: the formatter, the resolved instance, a lazy client."""
37
+
38
+ def __init__(self, args: argparse.Namespace, fmt: Formatter):
39
+ self.args = args
40
+ self.out = fmt
41
+ self.config = Config.load()
42
+ self._client = None # type: Optional[Any]
43
+ self._resolved = None # type: Optional[Any]
44
+
45
+ @property
46
+ def resolved(self):
47
+ if self._resolved is None:
48
+ self._resolved = resolve(url=getattr(self.args, "url", None),
49
+ token=getattr(self.args, "token", None),
50
+ profile=getattr(self.args, "profile", None),
51
+ config=self.config)
52
+ return self._resolved
53
+
54
+ def client(self, auth_required: bool = True, session: bool = False):
55
+ """The API client for this invocation, built once.
56
+
57
+ `auth_required=False` is for the public surfaces (STAC, OGC, templates): those work with a
58
+ URL alone, and demanding a token to read what the whole internet can read would be silly.
59
+
60
+ `session=True` marks a command that hits a route which REJECTS API tokens by design
61
+ (`/admin/*`, `/tokens`, ownership transfer). It prefers a stored password session; with
62
+ only a token available it still sends it, so the user gets the server's own 403 — which
63
+ `admin.py` rewrites into "run `geodeploy login --password`" — rather than a client-side
64
+ guess about what the instance would have said.
65
+ """
66
+ if self._client is None:
67
+ info = self.resolved
68
+ if not info.url:
69
+ raise AuthError(401, "No instance configured.", "")
70
+ token, jwt = info.token, info.jwt
71
+ if session and jwt:
72
+ token = None # a session outranks a token for these routes
73
+ if auth_required and not token and not jwt:
74
+ raise AuthError(401, "No credentials for {0}.".format(info.url), "")
75
+ from ..client import Client
76
+ self._client = Client(
77
+ info.url, token=token, jwt=jwt,
78
+ timeout=getattr(self.args, "timeout", None) or 120.0,
79
+ verify_tls=not getattr(self.args, "insecure", False),
80
+ on_request=(lambda method, url: self.out.debug("{0} {1}".format(method, url)))
81
+ if self.out.verbose else None)
82
+ self.out.debug("instance {0} (from {1}), credential from {2}".format(
83
+ info.url, info.source_url, info.source_token))
84
+ return self._client
85
+
86
+
87
+ def _global_flags() -> argparse.ArgumentParser:
88
+ """The flags every command accepts, as a PARENT parser.
89
+
90
+ Attached to the root *and* to every leaf command, because `geodeploy layers list --json` is
91
+ what people type — argparse would otherwise only accept `geodeploy --json layers list`, which
92
+ nobody does twice. `SUPPRESS` as the default is what makes that safe: an unmentioned flag on
93
+ the subparser leaves the root's value alone instead of resetting it.
94
+ """
95
+ parent = argparse.ArgumentParser(add_help=False)
96
+ common = parent.add_argument_group("connection")
97
+ common.add_argument("-p", "--profile", default=argparse.SUPPRESS,
98
+ help="use a saved profile (see `geodeploy profile`)")
99
+ common.add_argument("--url", default=argparse.SUPPRESS,
100
+ help="instance URL, overriding the profile and GEODEPLOY_URL")
101
+ common.add_argument("--token", default=argparse.SUPPRESS,
102
+ help="API token, overriding the stored one and GEODEPLOY_TOKEN")
103
+ common.add_argument("--timeout", type=float, default=argparse.SUPPRESS,
104
+ help="seconds to wait for an API call (default 120)")
105
+ common.add_argument("--insecure", action="store_true", default=argparse.SUPPRESS,
106
+ help="skip TLS verification (self-signed instances only)")
107
+
108
+ fmt = parent.add_argument_group("output")
109
+ fmt.add_argument("--json", action="store_true", dest="json_mode", default=argparse.SUPPRESS,
110
+ help="machine-readable JSON on stdout, and nothing else")
111
+ fmt.add_argument("-q", "--quiet", action="store_true", default=argparse.SUPPRESS,
112
+ help="only errors")
113
+ fmt.add_argument("-v", "--verbose", action="store_true", default=argparse.SUPPRESS,
114
+ help="log each request to stderr")
115
+ return parent
116
+
117
+
118
+ GLOBAL_FLAGS = _global_flags()
119
+
120
+
121
+ def build_parser() -> argparse.ArgumentParser:
122
+ parser = argparse.ArgumentParser(
123
+ prog="geodeploy",
124
+ description="Upload data, build portals and operate a GeoDeploy instance from the shell.",
125
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter,
126
+ parents=[GLOBAL_FLAGS])
127
+ parser.add_argument("--version", action="version", version="geodeploy {0}".format(__version__))
128
+ # NO `set_defaults` for the global flags here. `parents=` shares ACTION OBJECTS between this
129
+ # parser and every leaf, and `set_defaults` rewrites `action.default` in place — which would
130
+ # replace the SUPPRESS above with a real default on every leaf, so `geodeploy --json layers
131
+ # list` would have its --json reset to False by the leaf parser. Callers read these with
132
+ # `getattr(args, name, default)` instead.
133
+
134
+ subparsers = parser.add_subparsers(dest="_group", metavar="<command>")
135
+
136
+ from .commands import (admin, auth, browse, catalog, imports, jobs, layers, portals,
137
+ sources, upload)
138
+ for module in (auth, browse, upload, layers, portals, sources, imports, jobs,
139
+ catalog, admin):
140
+ module.register(subparsers)
141
+ return parser
142
+
143
+
144
+ def _tolerate_legacy_console() -> None:
145
+ """Never let a code-page limitation turn help text into a traceback.
146
+
147
+ `Formatter` degrades typographic characters it knows about, but argparse writes help and usage
148
+ straight to the stream, and an em dash in a `--help` epilog would raise UnicodeEncodeError on a
149
+ Windows console still running code page 437. Mojibake is a cosmetic problem; a crash printing
150
+ help is not.
151
+ """
152
+ for stream in (sys.stdout, sys.stderr):
153
+ reconfigure = getattr(stream, "reconfigure", None)
154
+ if reconfigure is not None:
155
+ try:
156
+ reconfigure(errors="replace")
157
+ except (ValueError, OSError): # pragma: no cover - a stream that cannot be reconfigured
158
+ pass
159
+
160
+
161
+ def main(argv: Optional[List[str]] = None) -> int:
162
+ _tolerate_legacy_console()
163
+ parser = build_parser()
164
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
165
+ fmt = Formatter(json_mode=getattr(args, "json_mode", False),
166
+ quiet=getattr(args, "quiet", False),
167
+ verbose=getattr(args, "verbose", False))
168
+
169
+ handler = getattr(args, "_handler", None)
170
+ if handler is None:
171
+ # A bare `geodeploy`, or a group with no command: show that group's help, not a traceback.
172
+ sub = getattr(args, "_subparser", None)
173
+ (sub or parser).print_help(sys.stderr)
174
+ return EXIT_USAGE
175
+
176
+ ctx = Context(args, fmt)
177
+ try:
178
+ return handler(ctx, args) or EXIT_OK
179
+ except KeyboardInterrupt:
180
+ fmt.error("Interrupted.")
181
+ return EXIT_GENERIC
182
+ except AuthError as exc:
183
+ fmt.error(exc.detail or "Authentication failed.", hint=_auth_hint(ctx, exc))
184
+ return EXIT_AUTH
185
+ except PermissionError_ as exc:
186
+ scope = exc.missing_scope
187
+ fmt.error(exc.detail or "Not allowed.",
188
+ hint=("Mint a token with the {0} scope (Settings → API tokens)."
189
+ .format(scope) if scope else
190
+ "Your role may be too low for this — an editor cannot administer an "
191
+ "instance, and administration is session-only anyway."))
192
+ return EXIT_AUTH
193
+ except ServerError as exc:
194
+ fmt.error(exc.detail or "The instance returned an error.",
195
+ hint="Check `geodeploy admin health`, or the service logs.")
196
+ return EXIT_SERVER
197
+ except TransportError as exc:
198
+ fmt.error(str(exc), hint="If the host is right, check the instance is up and reachable.")
199
+ return EXIT_NETWORK
200
+ except JobTimeout as exc:
201
+ fmt.error(str(exc))
202
+ return EXIT_GENERIC
203
+ except JobFailed as exc:
204
+ fmt.error("Ingest failed: {0}".format(exc),
205
+ hint="`geodeploy layers reprocess <layer>` restarts it without re-uploading.")
206
+ return EXIT_GENERIC
207
+ except ValidationError as exc:
208
+ # A ValidationError raised before any request has no URL: that is the CLI rejecting the
209
+ # arguments, which is a usage error (2). One that came back from the instance is a failed
210
+ # operation (1) — the arguments were fine, the data or the state was not.
211
+ fmt.error(exc.detail or str(exc))
212
+ return EXIT_GENERIC if exc.url else EXIT_USAGE
213
+ except APIError as exc:
214
+ fmt.error(exc.detail or str(exc))
215
+ return EXIT_GENERIC
216
+ except ConfigError as exc:
217
+ fmt.error(str(exc))
218
+ return EXIT_USAGE
219
+ except GeoDeployError as exc:
220
+ fmt.error(str(exc))
221
+ return EXIT_GENERIC
222
+ except BrokenPipeError: # `geodeploy … | head` — not an error worth a message
223
+ return EXIT_OK
224
+ except OSError as exc:
225
+ fmt.error(str(exc))
226
+ return EXIT_GENERIC
227
+
228
+
229
+ def _auth_hint(ctx: Context, exc: AuthError) -> str:
230
+ info = ctx.resolved
231
+ if not info.url:
232
+ return "Run `geodeploy login <instance-url>`, or set GEODEPLOY_URL."
233
+ if not info.token:
234
+ return ("Run `geodeploy login {0}` with a token from Settings → API tokens, "
235
+ "or set GEODEPLOY_TOKEN.".format(info.url))
236
+ return ("The credential for {0} was refused — it may be revoked or expired. "
237
+ "`geodeploy login {0}` stores a new one.".format(info.url))
238
+
239
+
240
+ def add_command(group, name: str, handler, help_text: str, aliases=(), epilog: Optional[str] = None):
241
+ """Register one command on a group's subparser set, wiring its handler and help."""
242
+ parser = group.add_parser(name, help=help_text, description=help_text, aliases=list(aliases),
243
+ epilog=epilog, parents=[GLOBAL_FLAGS],
244
+ formatter_class=argparse.RawDescriptionHelpFormatter)
245
+ parser.set_defaults(_handler=handler)
246
+ return parser
247
+
248
+
249
+ def group_parser(subparsers, name: str, help_text: str, aliases=()):
250
+ """Register a command GROUP (`geodeploy layers …`) and return its own subparser set.
251
+
252
+ `_subparser` is stashed so that `geodeploy layers` with no command prints the group's help
253
+ rather than the root's — the root's help is a wall, and the user has already narrowed down.
254
+ """
255
+ parser = subparsers.add_parser(name, help=help_text, description=help_text,
256
+ aliases=list(aliases),
257
+ formatter_class=argparse.RawDescriptionHelpFormatter)
258
+ parser.set_defaults(_subparser=parser)
259
+ return parser.add_subparsers(dest="_command", metavar="<subcommand>")
260
+
261
+
262
+ if __name__ == "__main__": # pragma: no cover
263
+ sys.exit(main())