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
gluekit/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .job_mgmt import glue_jobs as glue_jobs
6
+
7
+ sys.modules.setdefault(f"{__name__}.glue_jobs", glue_jobs)
gluekit/app.py ADDED
File without changes
gluekit/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ # ruff: noqa: E402,F401,F403
2
+ """Gluekit CLI entry point for local-first Glue job workflows.
3
+
4
+ The current command surface centers on a single active checkout context:
5
+ - `gluekit checkout <job-name>` selects the default local target.
6
+ - `gluekit edit` is the primary command for explicit local config mutations.
7
+ - `gluekit add` and `gluekit update` remain as compatibility shims.
8
+ - `gluekit run` executes Glue scripts locally with runtime emulators.
9
+ - `gluekit sync`, `gluekit convert`, and `gluekit remove` can also default to it.
10
+ - `gluekit pull` and `gluekit push` still support explicit job names and `*`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import typer
16
+
17
+ app = typer.Typer(no_args_is_help=True, rich_markup_mode="markdown")
18
+ glue_config_app = typer.Typer(
19
+ no_args_is_help=True,
20
+ rich_markup_mode="markdown",
21
+ help="Show and set local reusable Glue config parameters.",
22
+ )
23
+ app.add_typer(glue_config_app, name="config")
24
+ glue_local_app = typer.Typer(
25
+ no_args_is_help=True,
26
+ rich_markup_mode="markdown",
27
+ help="Manage local-only Glue development setups and mocked AWS fixtures.",
28
+ )
29
+ app.add_typer(glue_local_app, name="local")
30
+
31
+ # Import all commands to register them
32
+ from .commands import (
33
+ build,
34
+ checkout,
35
+ clone,
36
+ config_commands,
37
+ convert,
38
+ edit,
39
+ init,
40
+ list,
41
+ local_commands,
42
+ pull,
43
+ push,
44
+ run,
45
+ remove,
46
+ status,
47
+ sync,
48
+ add,
49
+ update,
50
+ )
51
+
52
+ __all__ = ["app", "glue_config_app", "glue_local_app"]
53
+
54
+
55
+ from .commands.constants import *
56
+ from .commands.helpers import *
57
+ from .commands.config_commands import (
58
+ _coerce_set_value,
59
+ _parse_set_args,
60
+ _to_csv_if_list,
61
+ _set_if_changed,
62
+ _write_config_changes,
63
+ )
64
+ from .commands.build import run_project_build, _build_zip_artifacts_from_sdists
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,455 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Optional
5
+
6
+ import typer
7
+ from slugify import slugify
8
+
9
+ from ..job_mgmt.glue_jobs import _resolve_notebook_path
10
+ from .helpers import (
11
+ _derive_s3_target,
12
+ _emit_compatibility_notice,
13
+ _examples_epilog,
14
+ _get_checked_out_jobs,
15
+ _load_config_index,
16
+ _looks_like_remote_module_spec,
17
+ _raise_missing_local_config,
18
+ _resolve_single_job_name,
19
+ _routes_to_additional_python_modules,
20
+ _write_config_changes,
21
+ )
22
+ from .edit import (
23
+ _append_csv_items,
24
+ _parse_csv_list,
25
+ _remove_csv_items,
26
+ )
27
+ from .convert import _update_notebook_config_cell
28
+ from ..cli import app
29
+
30
+
31
+ def _resolve_glue_add_target(
32
+ args: list[str],
33
+ *,
34
+ job_name: Optional[str],
35
+ ) -> tuple[str, list[str]]:
36
+ # Keep `gluekit add` context-first: it uses the active checked-out job by
37
+ # default, and `--job-name` is a one-command override similar to `gh --repo`.
38
+ if not args:
39
+ raise typer.BadParameter("Provide one or more paths or PyPI modules.")
40
+
41
+ if job_name:
42
+ return job_name, args
43
+
44
+ resolved_job_name = _resolve_single_job_name(None, "glue add")
45
+ return resolved_job_name, args
46
+
47
+
48
+ def _resolve_package_wheel_path(dist_dir: Path = Path("dist")) -> Path:
49
+ if not dist_dir.exists():
50
+ raise typer.BadParameter(f"Package wheel directory not found: {dist_dir}")
51
+ if not dist_dir.is_dir():
52
+ raise typer.BadParameter(f"Package wheel path is not a directory: {dist_dir}")
53
+
54
+ wheel_paths = sorted(dist_dir.glob("*.whl"))
55
+ if not wheel_paths:
56
+ raise typer.BadParameter(f"No package wheel found matching {dist_dir}/*.whl")
57
+ if len(wheel_paths) > 1:
58
+ rendered = ", ".join(path.as_posix() for path in wheel_paths)
59
+ raise typer.BadParameter(
60
+ f"Multiple package wheels found matching {dist_dir}/*.whl: {rendered}. "
61
+ "Pass the intended wheel path explicitly."
62
+ )
63
+ return wheel_paths[0]
64
+
65
+
66
+ def _is_package_wheel_local_path(value: str) -> bool:
67
+ path = Path(value)
68
+ return (
69
+ len(path.parts) >= 2
70
+ and path.parts[0] == "dist"
71
+ and path.suffix.lower() == ".whl"
72
+ )
73
+
74
+
75
+ def _remove_tracked_package_wheels(config_data: dict[str, Any]) -> list[str]:
76
+ default_args = config_data.setdefault("DefaultArguments", {})
77
+ sc = config_data.setdefault("SourceControlDetails", {})
78
+ additional_files = sc.get("AdditionalPythonFiles")
79
+ if additional_files is None:
80
+ return []
81
+ if not isinstance(additional_files, list):
82
+ raise typer.BadParameter(
83
+ "SourceControlDetails.AdditionalPythonFiles must be a list."
84
+ )
85
+
86
+ remaining_entries: list[dict[str, str]] = []
87
+ removed_s3_paths: list[str] = []
88
+ changes: list[str] = []
89
+
90
+ for entry in additional_files:
91
+ if not isinstance(entry, dict):
92
+ raise typer.BadParameter("AdditionalPythonFiles entries must be objects.")
93
+ local_path = entry.get("LocalPath")
94
+ s3_path = entry.get("S3Path")
95
+ if not local_path or not s3_path:
96
+ raise typer.BadParameter(
97
+ "AdditionalPythonFiles entries must include LocalPath and S3Path."
98
+ )
99
+ if _is_package_wheel_local_path(local_path):
100
+ removed_s3_paths.append(s3_path)
101
+ changes.append(f"Removed package wheel mapping: {local_path}")
102
+ continue
103
+ remaining_entries.append(entry)
104
+
105
+ if len(remaining_entries) != len(additional_files):
106
+ if remaining_entries:
107
+ sc["AdditionalPythonFiles"] = remaining_entries
108
+ else:
109
+ sc.pop("AdditionalPythonFiles", None)
110
+
111
+ updated_modules, removed_modules = _remove_csv_items(
112
+ default_args.get("--additional-python-modules"),
113
+ removed_s3_paths,
114
+ )
115
+ if removed_modules:
116
+ if updated_modules:
117
+ default_args["--additional-python-modules"] = updated_modules
118
+ else:
119
+ default_args.pop("--additional-python-modules", None)
120
+ changes.append(
121
+ f"Removed --additional-python-modules entries: {', '.join(removed_modules)}"
122
+ )
123
+
124
+ updated_extra_py_files, removed_extra_py_files = _remove_csv_items(
125
+ default_args.get("--extra-py-files"),
126
+ removed_s3_paths,
127
+ )
128
+ if removed_extra_py_files:
129
+ if updated_extra_py_files:
130
+ default_args["--extra-py-files"] = updated_extra_py_files
131
+ else:
132
+ default_args.pop("--extra-py-files", None)
133
+ changes.append(
134
+ f"Removed --extra-py-files entries: {', '.join(removed_extra_py_files)}"
135
+ )
136
+
137
+ return changes
138
+
139
+
140
+ def _resolve_glue_add_package_wheel_target(
141
+ args: list[str],
142
+ *,
143
+ job_name: Optional[str],
144
+ config_index: dict[str, dict[str, Any]],
145
+ ) -> str:
146
+ if job_name:
147
+ if args:
148
+ raise typer.BadParameter(
149
+ "Do not pass positional args with --job-name and --package-whl."
150
+ )
151
+ return job_name
152
+
153
+ if len(args) == 1 and args[0] in config_index:
154
+ return args[0]
155
+ if args:
156
+ raise typer.BadParameter(
157
+ "With --package-whl, pass at most one positional Glue job name."
158
+ )
159
+
160
+ return _resolve_single_job_name(None, "glue add --package-whl")
161
+
162
+
163
+ def _sync_existing_notebook_config(
164
+ *,
165
+ config_data: dict[str, Any],
166
+ job_name: str,
167
+ dry_run: bool,
168
+ ) -> bool:
169
+ sc = config_data.get("SourceControlDetails", {})
170
+ if not isinstance(sc, dict):
171
+ return False
172
+
173
+ script_path = Path(
174
+ sc.get("ScriptLocation")
175
+ or sc.get("LocalPath")
176
+ or f"glue/scripts/{slugify(job_name)}.py"
177
+ )
178
+ notebook_value = sc.get("NotebookLocation") or sc.get("NotebookPath")
179
+ notebook_path = (
180
+ Path(notebook_value)
181
+ if isinstance(notebook_value, str)
182
+ else Path(_resolve_notebook_path(script_path))
183
+ )
184
+ if not notebook_path.exists():
185
+ return False
186
+
187
+ return _update_notebook_config_cell(
188
+ notebook_path,
189
+ config_data,
190
+ {"--additional-python-modules"},
191
+ dry_run,
192
+ )
193
+
194
+
195
+ def _apply_add_mutations(
196
+ *,
197
+ config_data: dict[str, Any],
198
+ job_name: str,
199
+ items: list[str],
200
+ as_path: bool,
201
+ as_pypi: bool,
202
+ ) -> list[str]:
203
+ default_args = config_data.setdefault("DefaultArguments", {})
204
+ sc = config_data.setdefault("SourceControlDetails", {})
205
+
206
+ command = config_data.get("Command", {})
207
+ script_location = command.get("ScriptLocation")
208
+ if not script_location:
209
+ raise typer.BadParameter("Missing Command.ScriptLocation in config.")
210
+ local_script_path = Path(
211
+ sc.get("ScriptLocation")
212
+ or sc.get("LocalPath")
213
+ or f"glue/scripts/{slugify(job_name)}.py"
214
+ )
215
+
216
+ modules: list[str] = []
217
+ paths: list[Path] = []
218
+
219
+ for item in items:
220
+ if as_path:
221
+ kind = "path"
222
+ elif as_pypi:
223
+ kind = "pypi"
224
+ elif _looks_like_remote_module_spec(item):
225
+ kind = "pypi"
226
+ else:
227
+ looks_like_path = (
228
+ "/" in item
229
+ or "\\" in item
230
+ or item.startswith(".")
231
+ or Path(item).suffix in {".py", ".zip"}
232
+ or Path(item).exists()
233
+ )
234
+ kind = "path" if looks_like_path else "pypi"
235
+
236
+ if kind == "path":
237
+ path = Path(item)
238
+ if path.is_absolute():
239
+ raise typer.BadParameter(f"Local path must be relative: {path}")
240
+ if not path.exists():
241
+ raise typer.BadParameter(f"Local path not found: {path}")
242
+ paths.append(path)
243
+ else:
244
+ modules.append(item.strip())
245
+
246
+ updates: list[str] = []
247
+
248
+ if modules:
249
+ updated = _append_csv_items(
250
+ default_args.get("--additional-python-modules"), modules
251
+ )
252
+ if updated != default_args.get("--additional-python-modules", ""):
253
+ default_args["--additional-python-modules"] = updated
254
+ updates.append(f"Added python modules: {', '.join(modules)}")
255
+
256
+ if paths:
257
+ additional_files = sc.get("AdditionalPythonFiles")
258
+ if additional_files is None:
259
+ additional_files = []
260
+ if not isinstance(additional_files, list):
261
+ raise typer.BadParameter(
262
+ "SourceControlDetails.AdditionalPythonFiles must be a list."
263
+ )
264
+
265
+ local_to_entry: dict[str, dict[str, str]] = {}
266
+ s3_to_local: dict[str, str] = {}
267
+ for entry in additional_files:
268
+ if not isinstance(entry, dict):
269
+ raise typer.BadParameter(
270
+ "AdditionalPythonFiles entries must be objects."
271
+ )
272
+ local = entry.get("LocalPath")
273
+ s3_path = entry.get("S3Path")
274
+ if not local or not s3_path:
275
+ raise typer.BadParameter(
276
+ "AdditionalPythonFiles entries must include LocalPath and S3Path."
277
+ )
278
+ if local in local_to_entry:
279
+ raise typer.BadParameter(
280
+ f"Duplicate LocalPath in AdditionalPythonFiles: {local}"
281
+ )
282
+ if s3_path in s3_to_local and s3_to_local[s3_path] != local:
283
+ raise typer.BadParameter(
284
+ f"S3Path {s3_path} already mapped to {s3_to_local[s3_path]}"
285
+ )
286
+ local_to_entry[local] = entry
287
+ s3_to_local[s3_path] = local
288
+
289
+ existing_extra = _parse_csv_list(default_args.get("--extra-py-files"))
290
+ existing_modules = _parse_csv_list(
291
+ default_args.get("--additional-python-modules")
292
+ )
293
+ extra_list = list(existing_extra)
294
+ extra_set = set(existing_extra)
295
+ module_list = list(existing_modules)
296
+ module_set = set(existing_modules)
297
+
298
+ for path in paths:
299
+ s3_path = _derive_s3_target(
300
+ path, job_name, script_location, local_script_path
301
+ )
302
+ if path.is_dir() and not s3_path.endswith(".zip"):
303
+ s3_path = f"{s3_path}.zip"
304
+ use_additional_modules = _routes_to_additional_python_modules(path)
305
+
306
+ local_key = path.as_posix()
307
+ if s3_path in s3_to_local and s3_to_local[s3_path] != local_key:
308
+ raise typer.BadParameter(
309
+ f"S3Path {s3_path} already mapped to {s3_to_local[s3_path]}"
310
+ )
311
+
312
+ existing_entry = local_to_entry.get(local_key)
313
+ if existing_entry:
314
+ old_s3 = existing_entry.get("S3Path")
315
+ if old_s3 != s3_path:
316
+ existing_entry["S3Path"] = s3_path
317
+ s3_to_local.pop(old_s3, None)
318
+ s3_to_local[s3_path] = local_key
319
+ if old_s3 in extra_set:
320
+ extra_set.remove(old_s3)
321
+ if old_s3 in extra_list:
322
+ extra_list.remove(old_s3)
323
+ updates.append(
324
+ f"Updated python file mapping: {local_key} -> {s3_path}"
325
+ )
326
+ else:
327
+ new_entry = {"LocalPath": local_key, "S3Path": s3_path}
328
+ additional_files.append(new_entry)
329
+ local_to_entry[local_key] = new_entry
330
+ s3_to_local[s3_path] = local_key
331
+ updates.append(f"Added python file mapping: {local_key} -> {s3_path}")
332
+
333
+ if use_additional_modules:
334
+ if s3_path in extra_set:
335
+ extra_set.remove(s3_path)
336
+ if s3_path in extra_list:
337
+ extra_list.remove(s3_path)
338
+ updates.append(f"Removed --extra-py-files entry: {s3_path}")
339
+ if s3_path not in module_set:
340
+ module_set.add(s3_path)
341
+ module_list.append(s3_path)
342
+ updates.append(
343
+ f"Added --additional-python-modules entry: {s3_path}"
344
+ )
345
+ else:
346
+ if s3_path not in extra_set:
347
+ extra_set.add(s3_path)
348
+ extra_list.append(s3_path)
349
+ updates.append(f"Added --extra-py-files entry: {s3_path}")
350
+
351
+ if updates:
352
+ sc["AdditionalPythonFiles"] = additional_files
353
+ if extra_list:
354
+ default_args["--extra-py-files"] = ",".join(extra_list)
355
+ else:
356
+ default_args.pop("--extra-py-files", None)
357
+ if module_list:
358
+ default_args["--additional-python-modules"] = ",".join(module_list)
359
+ elif not modules:
360
+ default_args.pop("--additional-python-modules", None)
361
+
362
+ return updates
363
+
364
+
365
+ @app.command(
366
+ "add",
367
+ help="Compatibility command for inferred artifact and module additions. Prefer edit for explicit config changes.",
368
+ epilog=_examples_epilog(
369
+ "gluekit checkout my-job",
370
+ "gluekit add dist/my-package.whl",
371
+ "gluekit add --job-name other-job requests==2.32.3 --as-pypi",
372
+ ),
373
+ )
374
+ def glue_add(
375
+ args: Optional[list[str]] = typer.Argument(
376
+ None,
377
+ help="Paths or module specifiers to add to the selected Glue job config.",
378
+ ),
379
+ job_name: Optional[str] = typer.Option(
380
+ None,
381
+ "--job-name",
382
+ help="Override the active checked-out job for this command only.",
383
+ ),
384
+ as_path: bool = typer.Option(
385
+ False,
386
+ "--as-path",
387
+ help="Treat all items as local paths.",
388
+ ),
389
+ as_pypi: bool = typer.Option(
390
+ False,
391
+ "--as-pypi",
392
+ help="Treat all items as PyPI modules.",
393
+ ),
394
+ package_whl: bool = typer.Option(
395
+ False,
396
+ "--package-whl",
397
+ help="Use the package wheel from dist/*.whl.",
398
+ ),
399
+ dry_run: bool = typer.Option(
400
+ False,
401
+ "--dry-run",
402
+ help="Show what would be updated without writing files.",
403
+ ),
404
+ config_dir: Path = typer.Option(
405
+ Path("glue/configs"),
406
+ "--config-dir",
407
+ help="Directory containing Glue job config files.",
408
+ ),
409
+ ) -> None:
410
+ """Compatibility command for inferred artifact and module additions."""
411
+ if as_path and as_pypi:
412
+ raise typer.BadParameter("Use only one of --as-path or --as-pypi.")
413
+
414
+ config_index = _load_config_index(config_dir)
415
+ if package_whl:
416
+ job_name = _resolve_glue_add_package_wheel_target(
417
+ args or [],
418
+ job_name=job_name,
419
+ config_index=config_index,
420
+ )
421
+ items = [_resolve_package_wheel_path().as_posix()]
422
+ as_path = True
423
+ else:
424
+ job_name, items = _resolve_glue_add_target(args or [], job_name=job_name)
425
+ if not items:
426
+ raise typer.BadParameter("Provide one or more paths or PyPI modules.")
427
+
428
+ config_entry = config_index.get(job_name)
429
+ if not config_entry:
430
+ if job_name in _get_checked_out_jobs():
431
+ _raise_missing_local_config(job_name, config_dir, "glue add")
432
+ raise typer.BadParameter(
433
+ f'No config files matched "{job_name}" in {config_dir}.'
434
+ )
435
+
436
+ config_path: Path = config_entry["config_path"]
437
+ config_data = config_entry["config"]
438
+ _emit_compatibility_notice("add", "when you want explicit field-level mutations")
439
+ removal_updates = []
440
+ if package_whl:
441
+ removal_updates = _remove_tracked_package_wheels(config_data)
442
+ updates = removal_updates + _apply_add_mutations(
443
+ config_data=config_data,
444
+ job_name=job_name,
445
+ items=items,
446
+ as_path=as_path,
447
+ as_pypi=as_pypi,
448
+ )
449
+ _write_config_changes(config_path, config_data, updates, dry_run=dry_run)
450
+ if updates:
451
+ _sync_existing_notebook_config(
452
+ config_data=config_data,
453
+ job_name=job_name,
454
+ dry_run=dry_run,
455
+ )