gluekit 1.0.1.dev1__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.
Files changed (46) hide show
  1. gluekit/__init__.py +7 -0
  2. gluekit/app.py +0 -0
  3. gluekit/cli.py +64 -0
  4. gluekit/commands/__init__.py +1 -0
  5. gluekit/commands/add.py +455 -0
  6. gluekit/commands/build.py +816 -0
  7. gluekit/commands/checkout.py +114 -0
  8. gluekit/commands/clone.py +516 -0
  9. gluekit/commands/config_commands.py +180 -0
  10. gluekit/commands/constants.py +47 -0
  11. gluekit/commands/convert.py +336 -0
  12. gluekit/commands/edit.py +1104 -0
  13. gluekit/commands/helpers.py +1068 -0
  14. gluekit/commands/init.py +798 -0
  15. gluekit/commands/list.py +16 -0
  16. gluekit/commands/local_commands.py +680 -0
  17. gluekit/commands/pull.py +374 -0
  18. gluekit/commands/push.py +251 -0
  19. gluekit/commands/remove.py +161 -0
  20. gluekit/commands/run.py +126 -0
  21. gluekit/commands/status.py +97 -0
  22. gluekit/commands/sync.py +97 -0
  23. gluekit/commands/update.py +104 -0
  24. gluekit/job_mgmt/__init__.py +0 -0
  25. gluekit/job_mgmt/glue_jobs.py +1323 -0
  26. gluekit/job_mgmt/magics.py +122 -0
  27. gluekit/job_mgmt/resources/__init__.py +0 -0
  28. gluekit/job_mgmt/resources/glue_job_schema.json +40341 -0
  29. gluekit/job_mgmt/resources/magic_map.json +83 -0
  30. gluekit/job_mgmt/schema.py +165 -0
  31. gluekit/local/__init__.py +6 -0
  32. gluekit/local/awsglue/__init__.py +1 -0
  33. gluekit/local/awsglue/context.py +30 -0
  34. gluekit/local/awsglue/job.py +9 -0
  35. gluekit/local/awsglue/utils.py +17 -0
  36. gluekit/local/local.py +434 -0
  37. gluekit/local/local_fixtures.py +337 -0
  38. gluekit/local/pyspark/__init__.py +7 -0
  39. gluekit/local/pyspark/context.py +31 -0
  40. gluekit/local/pyspark/sql/__init__.py +6 -0
  41. gluekit/local/pyspark/sql/session.py +29 -0
  42. gluekit-1.0.1.dev1.dist-info/METADATA +1176 -0
  43. gluekit-1.0.1.dev1.dist-info/RECORD +46 -0
  44. gluekit-1.0.1.dev1.dist-info/WHEEL +5 -0
  45. gluekit-1.0.1.dev1.dist-info/entry_points.txt +2 -0
  46. gluekit-1.0.1.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+ import typer
3
+ from .helpers import *
4
+ from ..cli import app, glue_config_app
5
+
6
+
7
+ @app.command(
8
+ "list",
9
+ epilog=_examples_epilog(
10
+ "gluekit list --help",
11
+ "gluekit init --help",
12
+ ),
13
+ )
14
+ def glue_list() -> None:
15
+ """List real AWS Glue jobs in the configured AWS account."""
16
+ raise NotImplementedError("glue list command is not yet implemented.")
@@ -0,0 +1,680 @@
1
+ from __future__ import annotations
2
+
3
+ import posixpath
4
+ from json import dumps
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ..cli import glue_local_app
11
+ from ..local.local_fixtures import (
12
+ LOCAL_RUN_CONFIG_FILE,
13
+ copy_mapped_file,
14
+ expand_bucket_mappings,
15
+ expand_directory_mappings,
16
+ expand_s3_root_mapping,
17
+ is_s3_uri,
18
+ load_local_fixture_config,
19
+ normalize_s3_bucket_name,
20
+ normalize_s3_object_uri,
21
+ parse_s3_uri,
22
+ s3_destination_uri,
23
+ s3_directory_destination,
24
+ s3_prefix,
25
+ save_local_fixture_config,
26
+ )
27
+ from .helpers import (
28
+ _examples_epilog,
29
+ _get_checked_out_local_setup_name,
30
+ _get_local_checkouts,
31
+ _save_local_checkout,
32
+ )
33
+
34
+ local_s3_app = typer.Typer(
35
+ no_args_is_help=True,
36
+ rich_markup_mode="markdown",
37
+ help="Manage local mocked S3 fixture mappings; does not call AWS S3.",
38
+ )
39
+ local_ssm_app = typer.Typer(
40
+ no_args_is_help=True,
41
+ rich_markup_mode="markdown",
42
+ help="Manage local mocked SSM Parameter Store values; does not call AWS SSM.",
43
+ )
44
+ glue_local_app.add_typer(local_s3_app, name="s3")
45
+ glue_local_app.add_typer(local_ssm_app, name="ssm")
46
+ S3_BUCKET_ARG_TEMPLATE = "{s3 bucket in mocked profile}={local directory}"
47
+ SSM_PARAM_ARG_TEMPLATE = "{ssm parameter in mocked profile}={local parameter value}"
48
+
49
+
50
+ def _resolve_config_path(config_file: str | Path | None = None) -> Path:
51
+ if config_file is not None:
52
+ return Path(config_file)
53
+ return LOCAL_RUN_CONFIG_FILE
54
+
55
+
56
+ def _load_config(config_file: str | Path | None = None) -> dict[str, object]:
57
+ try:
58
+ return load_local_fixture_config(_resolve_config_path(config_file))
59
+ except ValueError as exc:
60
+ raise typer.BadParameter(str(exc)) from exc
61
+
62
+
63
+ def _save_config(
64
+ config: dict[str, object], config_file: str | Path | None = None
65
+ ) -> None:
66
+ try:
67
+ save_local_fixture_config(config, _resolve_config_path(config_file))
68
+ except ValueError as exc:
69
+ raise typer.BadParameter(str(exc)) from exc
70
+
71
+
72
+ def _s3_section(config: dict[str, object]) -> dict[str, object]:
73
+ return config["s3"] # type: ignore[return-value]
74
+
75
+
76
+ def _ssm_section(config: dict[str, object]) -> dict[str, object]:
77
+ return config["ssm"] # type: ignore[return-value]
78
+
79
+
80
+ def _s3_bucket_mappings(config: dict[str, object]) -> dict[str, str]:
81
+ return _s3_section(config)["buckets"] # type: ignore[return-value]
82
+
83
+
84
+ def _set_s3_root_mapping(config: dict[str, object], local_path: str) -> str:
85
+ source_dir = Path(local_path)
86
+ if not source_dir.exists() or not source_dir.is_dir():
87
+ raise typer.BadParameter(
88
+ f"Local S3 root directory must be an existing directory: {local_path!r}"
89
+ )
90
+ _s3_section(config)["root"] = str(source_dir)
91
+ return str(source_dir)
92
+
93
+
94
+ def _s3_objects(config: dict[str, object]) -> dict[str, str]:
95
+ return _s3_section(config)["objects"] # type: ignore[return-value]
96
+
97
+
98
+ def _s3_directories(config: dict[str, object]) -> list[dict[str, str]]:
99
+ return _s3_section(config)["directories"] # type: ignore[return-value]
100
+
101
+
102
+ def _ssm_parameters(config: dict[str, object]) -> dict[str, str]:
103
+ return _ssm_section(config)["parameters"] # type: ignore[return-value]
104
+
105
+
106
+ def _add_s3_bucket_mapping(
107
+ config: dict[str, object], *, bucket: str, local_path: str
108
+ ) -> str:
109
+ source_dir = Path(local_path)
110
+ if not source_dir.exists() or not source_dir.is_dir():
111
+ raise typer.BadParameter(
112
+ f"Local S3 bucket directory must be an existing directory: {local_path!r}"
113
+ )
114
+ bucket_name = normalize_s3_bucket_name(bucket)
115
+ _s3_bucket_mappings(config)[bucket_name] = str(source_dir)
116
+ return bucket_name
117
+
118
+
119
+ def _add_directory_mapping(
120
+ config: dict[str, object], *, local_path: str, destination: str
121
+ ) -> str:
122
+ source_dir = Path(local_path)
123
+ if not source_dir.exists() or not source_dir.is_dir():
124
+ raise typer.BadParameter(
125
+ f"Local source directory must be an existing directory: {local_path!r}"
126
+ )
127
+ destination_prefix = s3_directory_destination(destination)
128
+ directories = [
129
+ entry
130
+ for entry in _s3_directories(config)
131
+ if entry.get("local_path") != local_path
132
+ or entry.get("destination") != destination_prefix
133
+ ]
134
+ directories.append(
135
+ {
136
+ "local_path": str(source_dir),
137
+ "destination": destination_prefix,
138
+ }
139
+ )
140
+ _s3_section(config)["directories"] = sorted(
141
+ directories, key=lambda entry: (entry["destination"], entry["local_path"])
142
+ )
143
+ return destination_prefix
144
+
145
+
146
+ def _remove_directory_mapping(
147
+ config: dict[str, object], target: str
148
+ ) -> list[dict[str, str]]:
149
+ directories = _s3_directories(config)
150
+ normalized_target = target.strip()
151
+ destination_target = (
152
+ s3_directory_destination(normalized_target)
153
+ if is_s3_uri(normalized_target)
154
+ else None
155
+ )
156
+ kept: list[dict[str, str]] = []
157
+ removed: list[dict[str, str]] = []
158
+ for entry in directories:
159
+ local_path = entry.get("local_path", "")
160
+ destination = entry.get("destination", "")
161
+ if normalized_target == local_path or (
162
+ destination_target is not None and destination == destination_target
163
+ ):
164
+ removed.append(entry)
165
+ continue
166
+ kept.append(entry)
167
+ _s3_section(config)["directories"] = kept
168
+ return removed
169
+
170
+
171
+ def _parse_mapping_arg(raw: str, *, template: str) -> tuple[str, str]:
172
+ source, separator, local_value = raw.partition("=")
173
+ if not separator:
174
+ raise typer.BadParameter(f"Mapping must use {template}. Received: {raw!r}")
175
+ source = source.strip()
176
+ local_value = local_value.strip()
177
+ if not source or not local_value:
178
+ raise typer.BadParameter(f"Mapping must use {template}. Received: {raw!r}")
179
+ return source, local_value
180
+
181
+
182
+ def _require_s3_object(objects: dict[str, str], uri: str) -> str:
183
+ try:
184
+ return objects[uri]
185
+ except KeyError as exc:
186
+ raise typer.BadParameter(f"No mapped local file found for {uri}.") from exc
187
+
188
+
189
+ def _validate_ssm_name(name: str) -> str:
190
+ normalized = name.strip()
191
+ if not normalized or " " in normalized:
192
+ raise typer.BadParameter(
193
+ "SSM parameter names must be non-empty and contain no spaces."
194
+ )
195
+ return normalized
196
+
197
+
198
+ def _format_parameter(name: str, value: str) -> str:
199
+ return f"{name}\t{value}"
200
+
201
+
202
+ @glue_local_app.command(
203
+ "setup",
204
+ epilog=_examples_epilog(
205
+ "gluekit local setup --profile local-dev --s3-root sources/s3",
206
+ "gluekit local setup --s3-bucket local-dev=tests/fixtures",
207
+ "gluekit local setup --ssm-param /app/runtime=local",
208
+ ),
209
+ )
210
+ def local_setup(
211
+ profile: Optional[str] = typer.Option(
212
+ None,
213
+ "--profile",
214
+ help=(
215
+ "Optional gluekit/AWS profile name to associate with this local "
216
+ "setup in checkout state and .gluekit/local.json; this command "
217
+ "does not authenticate to AWS."
218
+ ),
219
+ ),
220
+ s3_bucket: Optional[list[str]] = typer.Option(
221
+ None,
222
+ "--s3-bucket",
223
+ help=(
224
+ "Map a mocked S3 bucket to a local directory in "
225
+ f"{S3_BUCKET_ARG_TEMPLATE} form. Repeat as needed."
226
+ ),
227
+ ),
228
+ s3_root: Optional[Path] = typer.Option(
229
+ None,
230
+ "--s3-root",
231
+ help=(
232
+ "Map a local directory as the full mocked S3 namespace for this "
233
+ "setup/profile. Expected layout: {local s3 root}/{bucket}/{prefix/key}."
234
+ ),
235
+ ),
236
+ ssm_param: Optional[list[str]] = typer.Option(
237
+ None,
238
+ "--ssm-param",
239
+ help=(
240
+ "Map a mocked SSM parameter to a local value in "
241
+ f"{SSM_PARAM_ARG_TEMPLATE} form. Repeat as needed."
242
+ ),
243
+ ),
244
+ ) -> None:
245
+ """Create or update and check out the local-only development setup."""
246
+ setup_data: dict[str, object] = {}
247
+ if profile is not None:
248
+ setup_data["profile"] = profile
249
+
250
+ config = _load_config(LOCAL_RUN_CONFIG_FILE)
251
+ if profile is not None:
252
+ config["profile"] = profile
253
+ if s3_root is not None:
254
+ _set_s3_root_mapping(config, str(s3_root))
255
+ if s3_bucket is not None:
256
+ _s3_section(config)["buckets"] = {}
257
+ _s3_section(config)["directories"] = []
258
+ for entry in s3_bucket:
259
+ bucket, local_path = _parse_mapping_arg(
260
+ entry, template=S3_BUCKET_ARG_TEMPLATE
261
+ )
262
+ _add_s3_bucket_mapping(config, bucket=bucket, local_path=local_path)
263
+ if ssm_param is not None:
264
+ _ssm_section(config)["parameters"] = {}
265
+ for entry in ssm_param:
266
+ parameter_name, value = _parse_mapping_arg(
267
+ entry, template=SSM_PARAM_ARG_TEMPLATE
268
+ )
269
+ _ssm_parameters(config)[_validate_ssm_name(parameter_name)] = value
270
+ _save_config(config, LOCAL_RUN_CONFIG_FILE)
271
+ _save_local_checkout("local", setup_data)
272
+ typer.echo(f"Saved and checked out local setup using {LOCAL_RUN_CONFIG_FILE}.")
273
+
274
+
275
+ @glue_local_app.command(
276
+ "status",
277
+ epilog=_examples_epilog("gluekit local status"),
278
+ )
279
+ def local_status() -> None:
280
+ """Show the active local development setup."""
281
+ active_name = _get_checked_out_local_setup_name()
282
+ setups = _get_local_checkouts()
283
+ if not setups:
284
+ typer.echo("No local setup saved.")
285
+ return
286
+ for name, setup in sorted(setups.items()):
287
+ marker = "*" if name == active_name else "-"
288
+ typer.echo(f"{marker} {name}")
289
+ typer.echo(dumps(setup, indent=4))
290
+
291
+
292
+ @local_s3_app.command(
293
+ "cp",
294
+ epilog=_examples_epilog(
295
+ "gluekit local s3 cp tests/fixtures/input.json s3://my-bucket/input.json",
296
+ "gluekit local s3 cp s3://my-bucket/input.json s3://my-bucket/copy.json",
297
+ "gluekit local s3 cp s3://my-bucket/input.json tmp/input.json",
298
+ ),
299
+ )
300
+ def local_s3_cp(
301
+ source: str = typer.Argument(..., help="Local path or mocked S3 URI source."),
302
+ destination: str = typer.Argument(
303
+ ..., help="Local path or mocked S3 URI destination."
304
+ ),
305
+ config_file: Optional[Path] = typer.Option(
306
+ None,
307
+ "--config-file",
308
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
309
+ ),
310
+ ) -> None:
311
+ """Copy a local file or mocked S3 object in the fixture config; does not call AWS."""
312
+ config = _load_config(config_file)
313
+ objects = _s3_objects(config)
314
+
315
+ if is_s3_uri(source) and is_s3_uri(destination):
316
+ source_uri = normalize_s3_object_uri(source)
317
+ source_path = _require_s3_object(objects, source_uri)
318
+ destination_uri = s3_destination_uri(
319
+ destination, posixpath.basename(source_uri)
320
+ )
321
+ objects[destination_uri] = source_path
322
+ _save_config(config, config_file)
323
+ typer.echo(f"copy: {source_uri} to {destination_uri}")
324
+ return
325
+
326
+ if is_s3_uri(source):
327
+ source_uri = normalize_s3_object_uri(source)
328
+ source_path = _require_s3_object(objects, source_uri)
329
+ destination_path = copy_mapped_file(source_path, destination)
330
+ typer.echo(f"download: {source_uri} to {destination_path}")
331
+ return
332
+
333
+ if is_s3_uri(destination):
334
+ source_path = Path(source)
335
+ if not source_path.is_file():
336
+ raise typer.BadParameter(
337
+ f"Local source must be an existing file: {source!r}"
338
+ )
339
+ destination_uri = s3_destination_uri(destination, source_path.name)
340
+ objects[destination_uri] = str(source_path)
341
+ _save_config(config, config_file)
342
+ typer.echo(f"upload: {source_path} to {destination_uri}")
343
+ return
344
+
345
+ raise typer.BadParameter("At least one path must be an S3 URI.")
346
+
347
+
348
+ @local_s3_app.command(
349
+ "mount",
350
+ epilog=_examples_epilog(
351
+ "gluekit local s3 mount tests/fixtures s3://local-dev/input/",
352
+ "gluekit local s3 mount data/mock s3://bucket-a",
353
+ ),
354
+ )
355
+ def local_s3_mount(
356
+ source_dir: str = typer.Argument(..., help="Local directory to map recursively."),
357
+ destination: str = typer.Argument(
358
+ ..., help="Mocked S3 bucket or prefix base for mapped files."
359
+ ),
360
+ config_file: Optional[Path] = typer.Option(
361
+ None,
362
+ "--config-file",
363
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
364
+ ),
365
+ ) -> None:
366
+ """Map a local directory recursively into mocked S3 keys; does not call AWS."""
367
+ if not is_s3_uri(destination):
368
+ raise typer.BadParameter("Destination must be an S3 URI.")
369
+ config = _load_config(config_file)
370
+ destination_prefix = _add_directory_mapping(
371
+ config,
372
+ local_path=source_dir,
373
+ destination=destination,
374
+ )
375
+ _save_config(config, config_file)
376
+ typer.echo(f"mount: {source_dir} to {destination_prefix}")
377
+
378
+
379
+ @local_s3_app.command(
380
+ "unmount",
381
+ epilog=_examples_epilog(
382
+ "gluekit local s3 unmount tests/fixtures",
383
+ "gluekit local s3 unmount s3://local-dev/input/",
384
+ ),
385
+ )
386
+ def local_s3_unmount(
387
+ target: str = typer.Argument(
388
+ ..., help="Local source directory or mocked S3 destination to remove."
389
+ ),
390
+ config_file: Optional[Path] = typer.Option(
391
+ None,
392
+ "--config-file",
393
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
394
+ ),
395
+ ) -> None:
396
+ """Remove a recursive local-directory-to-mocked-S3 mapping."""
397
+ config = _load_config(config_file)
398
+ removed = _remove_directory_mapping(config, target)
399
+ if not removed:
400
+ raise typer.BadParameter(f"No mounted directory found for {target!r}.")
401
+ _save_config(config, config_file)
402
+ for entry in removed:
403
+ typer.echo(f"unmount: {entry['local_path']} from {entry['destination']}")
404
+
405
+
406
+ @local_s3_app.command(
407
+ "mv",
408
+ epilog=_examples_epilog(
409
+ "gluekit local s3 mv s3://my-bucket/input.json s3://my-bucket/renamed.json",
410
+ "gluekit local s3 mv s3://my-bucket/input.json tmp/input.json",
411
+ ),
412
+ )
413
+ def local_s3_mv(
414
+ source: str = typer.Argument(..., help="Local path or mocked S3 URI source."),
415
+ destination: str = typer.Argument(
416
+ ..., help="Local path or mocked S3 URI destination."
417
+ ),
418
+ delete_local: bool = typer.Option(
419
+ False,
420
+ "--delete-local",
421
+ help="Delete the local source file after mapping a LocalPath to S3.",
422
+ ),
423
+ config_file: Optional[Path] = typer.Option(
424
+ None,
425
+ "--config-file",
426
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
427
+ ),
428
+ ) -> None:
429
+ """Move a local file or mocked S3 object in the fixture config; does not call AWS."""
430
+ config = _load_config(config_file)
431
+ objects = _s3_objects(config)
432
+
433
+ if is_s3_uri(source) and is_s3_uri(destination):
434
+ source_uri = normalize_s3_object_uri(source)
435
+ source_path = _require_s3_object(objects, source_uri)
436
+ destination_uri = s3_destination_uri(
437
+ destination, posixpath.basename(source_uri)
438
+ )
439
+ del objects[source_uri]
440
+ objects[destination_uri] = source_path
441
+ _save_config(config, config_file)
442
+ typer.echo(f"move: {source_uri} to {destination_uri}")
443
+ return
444
+
445
+ if is_s3_uri(source):
446
+ source_uri = normalize_s3_object_uri(source)
447
+ source_path = _require_s3_object(objects, source_uri)
448
+ destination_path = copy_mapped_file(source_path, destination)
449
+ del objects[source_uri]
450
+ _save_config(config, config_file)
451
+ typer.echo(f"move: {source_uri} to {destination_path}")
452
+ return
453
+
454
+ if is_s3_uri(destination):
455
+ source_path = Path(source)
456
+ if not source_path.is_file():
457
+ raise typer.BadParameter(
458
+ f"Local source must be an existing file: {source!r}"
459
+ )
460
+ destination_uri = s3_destination_uri(destination, source_path.name)
461
+ objects[destination_uri] = str(source_path)
462
+ if delete_local:
463
+ source_path.unlink()
464
+ _save_config(config, config_file)
465
+ typer.echo(f"move: {source_path} to {destination_uri}")
466
+ return
467
+
468
+ raise typer.BadParameter("At least one path must be an S3 URI.")
469
+
470
+
471
+ @local_s3_app.command(
472
+ "rm",
473
+ epilog=_examples_epilog(
474
+ "gluekit local s3 rm s3://my-bucket/input.json",
475
+ "gluekit local s3 rm s3://my-bucket/prefix/ --recursive",
476
+ ),
477
+ )
478
+ def local_s3_rm(
479
+ uri: str = typer.Argument(..., help="Mapped mocked S3 object or prefix to remove."),
480
+ recursive: bool = typer.Option(
481
+ False,
482
+ "--recursive",
483
+ "-r",
484
+ help="Remove all mapped objects under the S3 prefix.",
485
+ ),
486
+ config_file: Optional[Path] = typer.Option(
487
+ None,
488
+ "--config-file",
489
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
490
+ ),
491
+ ) -> None:
492
+ """Remove mapped mocked S3 objects from the local fixture config."""
493
+ config = _load_config(config_file)
494
+ objects = _s3_objects(config)
495
+ removed: list[str]
496
+ if recursive:
497
+ prefix = s3_prefix(uri)
498
+ removed = [
499
+ object_uri for object_uri in objects if object_uri.startswith(prefix)
500
+ ]
501
+ else:
502
+ object_uri = normalize_s3_object_uri(uri)
503
+ removed = [object_uri] if object_uri in objects else []
504
+
505
+ if not removed:
506
+ raise typer.BadParameter(f"No mapped S3 objects found for {uri}.")
507
+ for object_uri in removed:
508
+ del objects[object_uri]
509
+ _save_config(config, config_file)
510
+ for object_uri in removed:
511
+ typer.echo(f"delete: {object_uri}")
512
+
513
+
514
+ @local_s3_app.command("ls")
515
+ def local_s3_ls(
516
+ uri: Optional[str] = typer.Argument(None, help="Optional mocked S3 URI to list."),
517
+ config_file: Optional[Path] = typer.Option(
518
+ None,
519
+ "--config-file",
520
+ help="Local fixture config file to read. Defaults to .gluekit/local.json.",
521
+ ),
522
+ ) -> None:
523
+ """List mocked S3 buckets inferred from mappings, or mapped objects."""
524
+ config = _load_config(config_file)
525
+ objects = expand_s3_root_mapping(config)
526
+ objects.update(expand_bucket_mappings(config))
527
+ objects.update(_s3_objects(config))
528
+ objects.update(expand_directory_mappings(config))
529
+ if uri is None:
530
+ bucket_names = sorted({parse_s3_uri(object_uri)[0] for object_uri in objects})
531
+ for bucket_name in bucket_names:
532
+ typer.echo(bucket_name)
533
+ return
534
+
535
+ prefix = s3_prefix(uri)
536
+ for object_uri in sorted(
537
+ object_uri for object_uri in objects if object_uri.startswith(prefix)
538
+ ):
539
+ typer.echo(object_uri)
540
+
541
+
542
+ @local_ssm_app.command(
543
+ "put",
544
+ epilog=_examples_epilog("gluekit local ssm put /app/env dev"),
545
+ )
546
+ def local_ssm_put(
547
+ name: str = typer.Argument(..., help="Mocked SSM parameter name."),
548
+ value: str = typer.Argument(..., help="Mocked SSM parameter value."),
549
+ overwrite: bool = typer.Option(
550
+ True,
551
+ "--overwrite/--no-overwrite",
552
+ help="Overwrite an existing parameter.",
553
+ ),
554
+ config_file: Optional[Path] = typer.Option(
555
+ None,
556
+ "--config-file",
557
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
558
+ ),
559
+ ) -> None:
560
+ """Create or update a mocked SSM parameter with local-only syntax."""
561
+ _put_ssm_parameter(
562
+ name,
563
+ value,
564
+ overwrite=overwrite,
565
+ config_file=config_file,
566
+ )
567
+
568
+
569
+ def _put_ssm_parameter(
570
+ name: str,
571
+ value: str,
572
+ *,
573
+ overwrite: bool,
574
+ config_file: str | Path | None = None,
575
+ ) -> None:
576
+ parameter_name = _validate_ssm_name(name)
577
+ config = _load_config(config_file)
578
+ parameters = _ssm_parameters(config)
579
+ if parameter_name in parameters and not overwrite:
580
+ raise typer.BadParameter(
581
+ f"SSM parameter {parameter_name!r} already exists. Use --overwrite to replace it."
582
+ )
583
+ parameters[parameter_name] = value
584
+ _save_config(config, config_file)
585
+ typer.echo(f"put_parameter: {parameter_name}")
586
+
587
+
588
+ @local_ssm_app.command("get")
589
+ def local_ssm_get(
590
+ name: str = typer.Argument(..., help="Mocked SSM parameter name."),
591
+ config_file: Optional[Path] = typer.Option(
592
+ None,
593
+ "--config-file",
594
+ help="Local fixture config file to read. Defaults to .gluekit/local.json.",
595
+ ),
596
+ ) -> None:
597
+ """Show one mocked SSM parameter with local-only syntax."""
598
+ _get_ssm_parameter(name, config_file=config_file)
599
+
600
+
601
+ def _get_ssm_parameter(name: str, *, config_file: str | Path | None = None) -> None:
602
+ parameter_name = _validate_ssm_name(name)
603
+ parameter = _ssm_parameters(_load_config(config_file)).get(parameter_name)
604
+ if parameter is None:
605
+ raise typer.BadParameter(f"No SSM parameter found for {parameter_name!r}.")
606
+ typer.echo(_format_parameter(parameter_name, parameter))
607
+
608
+
609
+ @local_ssm_app.command("rm")
610
+ def local_ssm_rm(
611
+ name: str = typer.Argument(..., help="Mocked SSM parameter name."),
612
+ config_file: Optional[Path] = typer.Option(
613
+ None,
614
+ "--config-file",
615
+ help="Local fixture config file to update. Defaults to .gluekit/local.json.",
616
+ ),
617
+ ) -> None:
618
+ """Delete one mocked SSM parameter with local-only syntax."""
619
+ _delete_ssm_parameter(name, config_file=config_file)
620
+
621
+
622
+ def _delete_ssm_parameter(name: str, *, config_file: str | Path | None = None) -> None:
623
+ parameter_name = _validate_ssm_name(name)
624
+ config = _load_config(config_file)
625
+ parameters = _ssm_parameters(config)
626
+ if parameter_name not in parameters:
627
+ raise typer.BadParameter(f"No SSM parameter found for {parameter_name!r}.")
628
+ del parameters[parameter_name]
629
+ _save_config(config, config_file)
630
+ typer.echo(f"delete_parameter: {parameter_name}")
631
+
632
+
633
+ @local_ssm_app.command("ls")
634
+ def local_ssm_ls(
635
+ path: Optional[str] = typer.Argument(
636
+ None, help="Optional mocked SSM parameter hierarchy path."
637
+ ),
638
+ recursive: bool = typer.Option(
639
+ False,
640
+ "--recursive/--no-recursive",
641
+ help="List all nested parameters below the path.",
642
+ ),
643
+ config_file: Optional[Path] = typer.Option(
644
+ None,
645
+ "--config-file",
646
+ help="Local fixture config file to read. Defaults to .gluekit/local.json.",
647
+ ),
648
+ ) -> None:
649
+ """List mocked SSM parameters with local-only syntax."""
650
+ _list_ssm_parameters(path, recursive=recursive, config_file=config_file)
651
+
652
+
653
+ def _list_ssm_parameters(
654
+ path: str | None, *, recursive: bool, config_file: str | Path | None = None
655
+ ) -> None:
656
+ parameters = _ssm_parameters(_load_config(config_file))
657
+ if path is None:
658
+ for name, parameter in sorted(parameters.items()):
659
+ typer.echo(_format_parameter(name, parameter))
660
+ return
661
+
662
+ parameter_path = _validate_ssm_name(path).rstrip("/")
663
+ prefix = f"{parameter_path}/"
664
+ for name, parameter in sorted(parameters.items()):
665
+ if name == parameter_path:
666
+ typer.echo(_format_parameter(name, parameter))
667
+ continue
668
+ if not name.startswith(prefix):
669
+ continue
670
+ remainder = name[len(prefix) :]
671
+ if recursive or "/" not in remainder:
672
+ typer.echo(_format_parameter(name, parameter))
673
+
674
+
675
+ @glue_local_app.callback(invoke_without_command=True)
676
+ def local_callback(ctx: typer.Context) -> None:
677
+ """Manage local-only Glue development fixtures."""
678
+ if ctx.invoked_subcommand is None:
679
+ typer.echo(ctx.get_help())
680
+ raise typer.Exit()