prefect-deployments-toolkit 0.0.1__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,145 @@
1
+ """Entry point: parse args and apply all deployments concurrently.
2
+
3
+ Usage:
4
+ python -m apply_deployments \
5
+ --deployment-names "hello_world3,hello_world_4" \
6
+ --enable-schedule false \
7
+ --tag dev \
8
+ --reference feature_branch1 \
9
+ --repo-name edp-flows \
10
+ --custom-image "" \
11
+ --deployments-dir prefect/deployments \
12
+ --dev-work-pool lapp-dev-work-pool-prefect3 \
13
+ --backend cli \
14
+ --enforce-unique-deployment-names false
15
+ """
16
+
17
+ import argparse
18
+ import logging
19
+ import sys
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from pathlib import Path
22
+
23
+ from .deployment import DeploymentContext, apply_single_deployment
24
+ from .log_buffer import buffered_deployment_log
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ MAX_WORKERS = 8
29
+
30
+
31
+ def _parse_args() -> argparse.Namespace:
32
+ parser = argparse.ArgumentParser(description="Apply Prefect deployments.")
33
+ parser.add_argument("--deployment-names", required=True,
34
+ help="Comma-separated list of deployment names.")
35
+ parser.add_argument("--enable-schedule", required=True,
36
+ choices=["true", "false"],
37
+ help="Whether to enable schedules after deploy.")
38
+ parser.add_argument("--tag", required=True,
39
+ help="Image/environment tag (e.g. 'dev', 'v1.2.3').")
40
+ parser.add_argument("--reference", required=True,
41
+ help="Git branch or tag reference (e.g. 'main', 'feature_branch1').")
42
+ parser.add_argument("--repo-name", required=True,
43
+ help="Repository name used in job variable paths.")
44
+ parser.add_argument("--custom-image", default="",
45
+ help="Full custom image reference, or empty string to use pool default.")
46
+ parser.add_argument("--deployments-dir", default="deployments",
47
+ help="Path to the deployments directory.")
48
+ parser.add_argument("--dev-work-pool", default="",
49
+ help="Dev work pool name. When set with --tag=dev, activates dev overrides.")
50
+ parser.add_argument("--max-workers", type=int, default=MAX_WORKERS,
51
+ help=f"Max concurrent deployments (default: {MAX_WORKERS}).")
52
+ parser.add_argument("--backend", default="cli", choices=["cli", "rest"],
53
+ help="Deployment backend: 'cli' (prefect CLI, default) or "
54
+ "'rest' (direct Prefect Cloud REST API calls).")
55
+ parser.add_argument("--enforce-unique-deployment-names", default="false",
56
+ choices=["true", "false"],
57
+ help=(
58
+ "If false (default), only WARN in logs when a deployment name is used by"
59
+ "more than one flow — nothing is deleted. If true, delete every duplicate"
60
+ "deployment record except the one matching the flow currently resolved from"
61
+ "its entrypoint, enforcing globally unique deployment names."
62
+ ))
63
+ return parser.parse_args()
64
+
65
+
66
+ def _run_deployment(
67
+ deployment_name: str,
68
+ index: int,
69
+ total: int,
70
+ ctx: DeploymentContext,
71
+ ) -> str | None:
72
+ """Run a single deployment inside a buffered log context.
73
+
74
+ Returns the deployment_name if it failed, None on success.
75
+ """
76
+ separator = "#" * 60
77
+ with buffered_deployment_log(deployment_name):
78
+ logger.info("%s [%d/%d] %s %s", separator, index, total, deployment_name, separator)
79
+ try:
80
+ apply_single_deployment(deployment_name, ctx)
81
+ logger.info(">>> DONE [%d/%d] %s", index, total, deployment_name)
82
+ return None
83
+ except Exception as exc: # noqa: BLE001
84
+ logger.error(">>> FAILED [%d/%d] %s: %s", index, total, deployment_name, exc)
85
+ return deployment_name
86
+
87
+
88
+ def main() -> None:
89
+ logging.basicConfig(
90
+ level=logging.INFO,
91
+ format="%(asctime)s [%(levelname)s] %(message)s",
92
+ )
93
+ args = _parse_args()
94
+ names = [n.strip() for n in args.deployment_names.split(",") if n.strip()]
95
+
96
+ if not names:
97
+ logger.error("No deployment names provided.")
98
+ sys.exit(1)
99
+
100
+ ctx = DeploymentContext(
101
+ deployment_names=names,
102
+ enable_schedule=args.enable_schedule == "true",
103
+ tag=args.tag,
104
+ reference=args.reference,
105
+ repo_name=args.repo_name,
106
+ custom_image=args.custom_image,
107
+ deployments_dir=Path(args.deployments_dir),
108
+ dev_prefect_work_pool_name=args.dev_work_pool,
109
+ backend=args.backend,
110
+ enforce_unique_deployment_names=args.enforce_unique_deployment_names == "true",
111
+ )
112
+
113
+ total = len(names)
114
+ workers = min(args.max_workers, total)
115
+ logger.info(
116
+ "Applying %d deployment(s) with up to %d concurrent workers (backend=%s).",
117
+ total, workers, ctx.backend,
118
+ )
119
+
120
+ failed: list[str] = []
121
+
122
+ with ThreadPoolExecutor(max_workers=workers) as executor:
123
+ futures = {
124
+ executor.submit(_run_deployment, name, i, total, ctx): name
125
+ for i, name in enumerate(names, start=1)
126
+ }
127
+ for future in as_completed(futures):
128
+ result = future.result() # re-raises only unexpected executor errors
129
+ if result is not None:
130
+ failed.append(result)
131
+
132
+ if failed:
133
+ logger.error(
134
+ "%d deployment(s) failed: %s",
135
+ len(failed),
136
+ ", ".join(failed),
137
+ )
138
+ sys.exit(1)
139
+
140
+ logger.info("All %d deployment(s) applied successfully.", total)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
145
+
@@ -0,0 +1,250 @@
1
+ """Core logic for applying a single Prefect deployment."""
2
+
3
+ import logging
4
+ import tempfile
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from . import prefect_api, prefect_cli, prefect_rest, yaml_utils
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ DEV_PREFIX = "dev--"
14
+
15
+
16
+ @dataclass
17
+ class DeploymentContext:
18
+ """All runtime configuration for the apply run."""
19
+
20
+ deployment_names: list[str]
21
+ enable_schedule: bool
22
+ tag: str
23
+ reference: str
24
+ repo_name: str
25
+ custom_image: str
26
+ deployments_dir: Path
27
+ dev_prefect_work_pool_name: str = ""
28
+ backend: str = "cli" # "cli" or "rest"
29
+ enforce_unique_deployment_names: bool = False
30
+
31
+ @property
32
+ def is_dev(self) -> bool:
33
+ return bool(self.dev_prefect_work_pool_name) and self.tag == "dev"
34
+
35
+ @property
36
+ def is_non_default_branch(self) -> bool:
37
+ return self.reference not in {"main", "master"}
38
+
39
+ @property
40
+ def client(self):
41
+ """Return the backend module (prefect_cli or prefect_rest) for this run."""
42
+ return prefect_rest if self.backend == "rest" else prefect_cli
43
+
44
+
45
+ def _resolve_flow_name(deployment_name: str, ctx: DeploymentContext) -> tuple[str, str | None]:
46
+ """Return (flow_id, flow_name) for a deployment.
47
+
48
+ If more than one flow currently has a deployment with this name:
49
+ - default (enforce_unique_deployment_names=False): log a warning
50
+ listing every duplicate flow, and proceed using the FIRST match
51
+ returned by the API. Nothing is deleted.
52
+ - enforce mode (enforce_unique_deployment_names=True): proceed the
53
+ same way for now — the actual cleanup of duplicates happens AFTER
54
+ deploy, in apply_single_deployment, once we know the flow this
55
+ entrypoint currently resolves to.
56
+ """
57
+ flow_ids = prefect_api.get_flow_ids_for_deployment(deployment_name)
58
+
59
+ if len(flow_ids) <= 1:
60
+ flow_id = flow_ids[0] if flow_ids else ""
61
+ flow_name = prefect_api.get_flow_name(flow_id) if flow_id else None
62
+ return flow_id, flow_name
63
+
64
+ flow_names = [prefect_api.get_flow_name(fid) for fid in flow_ids]
65
+ logger.warning(
66
+ "Deployment name '%s' is currently used by %d different flows: %s. "
67
+ "Deployment names should be globally unique.",
68
+ deployment_name, len(flow_ids), flow_names,
69
+ )
70
+
71
+ if not ctx.enforce_unique_deployment_names:
72
+ logger.warning(
73
+ "enforce-unique-deployment-names is OFF — proceeding with flow '%s' "
74
+ "(first match) without deleting the others. Pass "
75
+ "--enforce-unique-deployment-names true to clean up duplicates.",
76
+ flow_names[0],
77
+ )
78
+ else:
79
+ logger.warning(
80
+ "enforce-unique-deployment-names is ON — duplicates not matching the "
81
+ "flow resolved from this deployment's entrypoint will be deleted after deploy."
82
+ )
83
+
84
+ return flow_ids[0], flow_names[0]
85
+
86
+
87
+ def _cleanup_duplicate_deployments(
88
+ ctx: DeploymentContext,
89
+ deployment_name: str,
90
+ current_flow_id: str,
91
+ current_flow_name: str,
92
+ ) -> None:
93
+ """Delete every deployment named `deployment_name` under a flow OTHER than
94
+ current_flow_id. Only called when ctx.enforce_unique_deployment_names=True.
95
+ """
96
+ flow_ids = prefect_api.get_flow_ids_for_deployment(deployment_name)
97
+ stale_flow_ids = [fid for fid in flow_ids if fid != current_flow_id]
98
+
99
+ for stale_flow_id in stale_flow_ids:
100
+ stale_flow_name = prefect_api.get_flow_name(stale_flow_id)
101
+ logger.warning(
102
+ "Deleting duplicate deployment '%s' under flow '%s' (flow_id=%s) — "
103
+ "keeping the one under current flow '%s' (flow_id=%s).",
104
+ deployment_name, stale_flow_name, stale_flow_id,
105
+ current_flow_name, current_flow_id,
106
+ )
107
+ ctx.client.delete_deployment(f"{stale_flow_name}/{deployment_name}")
108
+
109
+
110
+ def _build_tags(ctx: DeploymentContext, merged_file: Path, full_name: str) -> list[str]:
111
+ tags = [ctx.tag, ctx.reference]
112
+ tags += yaml_utils.get_deployment_tags(merged_file, full_name)
113
+ return tags
114
+
115
+
116
+ def _build_job_variables(
117
+ ctx: DeploymentContext,
118
+ merged_file: Path,
119
+ full_name: str,
120
+ ) -> dict[str, str]:
121
+ job_vars: dict[str, str] = {"name": full_name}
122
+
123
+ if ctx.is_non_default_branch:
124
+ base = f"/opt/prefect/{ctx.repo_name}-{ctx.reference}"
125
+ job_vars["DBT_PROJECT_DIR"] = f"{base}/src/edp_flows/models"
126
+ job_vars["DBT_PROFILES_DIR"] = f"{base}/src/edp_flows/models"
127
+ job_vars["METRICS_EXPORTER_DIR"] = f"{base}/etc"
128
+
129
+ if ctx.custom_image:
130
+ job_vars["image"] = ctx.custom_image
131
+
132
+ # Merge job_variables from YAML; image from YAML only wins if not already set above
133
+ yaml_job_vars = yaml_utils.get_job_variables(merged_file, full_name)
134
+ for key, value in yaml_job_vars.items():
135
+ if key == "image" and "image" in job_vars:
136
+ continue
137
+ job_vars[key] = str(value)
138
+
139
+ return job_vars
140
+
141
+
142
+ def _handle_schedules(
143
+ ctx: DeploymentContext,
144
+ merged_file: Path,
145
+ full_name: str,
146
+ flow_name: str,
147
+ ) -> None:
148
+ deployment_has_schedules = yaml_utils.has_schedules(merged_file, full_name)
149
+
150
+ if not deployment_has_schedules:
151
+ logger.info("No schedules in YAML — removing any existing schedules from Prefect Cloud...")
152
+ schedule_ids = prefect_api.get_schedule_ids(f"{flow_name}/{full_name}")
153
+ for sid in schedule_ids:
154
+ ctx.client.delete_schedule(f"{flow_name}/{full_name}", sid)
155
+ if not schedule_ids:
156
+ logger.info("No existing schedules to remove.")
157
+ return
158
+
159
+ if ctx.enable_schedule:
160
+ logger.info("Resuming all schedules for '%s'...", full_name)
161
+ schedule_ids = prefect_api.get_schedule_ids(f"{flow_name}/{full_name}")
162
+ for sid in schedule_ids:
163
+ ctx.client.resume_schedule(f"{flow_name}/{full_name}", sid)
164
+ else:
165
+ logger.info("Schedules exist in YAML but enable_schedule=false — will remain paused.")
166
+
167
+
168
+ def remove_deployment(ctx: DeploymentContext, deployment_name: str, full_name: str, flow_name: str | None) -> None:
169
+ """Delete a deployment that no longer exists in the YAML files."""
170
+ logger.info("Deployment '%s' has been removed from YAML.", deployment_name)
171
+ if flow_name:
172
+ ctx.client.delete_deployment(f"{flow_name}/{full_name}")
173
+ else:
174
+ logger.info(
175
+ "Deployment '%s' does not exist on Prefect Cloud (no matching flow). Skipping.",
176
+ full_name,
177
+ )
178
+
179
+
180
+ def apply_single_deployment(deployment_name: str, ctx: DeploymentContext) -> None:
181
+ """Apply (create/update) or remove a single deployment."""
182
+ yaml_file = yaml_utils.find_deployment_file(deployment_name, ctx.deployments_dir)
183
+ prefix = DEV_PREFIX if ctx.is_dev else ""
184
+ full_name = f"{prefix}{deployment_name}"
185
+
186
+ flow_id, flow_name = _resolve_flow_name(full_name, ctx)
187
+
188
+ if yaml_file is None:
189
+ remove_deployment(ctx, deployment_name, full_name, flow_name)
190
+ return
191
+
192
+ with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp:
193
+ merged_file = Path(tmp.name)
194
+
195
+ try:
196
+ yaml_utils.build_merged_prefect_file(
197
+ ctx.deployments_dir / "prefect_base.yaml",
198
+ yaml_file,
199
+ merged_file,
200
+ )
201
+
202
+ if ctx.is_dev:
203
+ yaml_utils.apply_dev_overrides(
204
+ merged_file, deployment_name, DEV_PREFIX, ctx.dev_prefect_work_pool_name
205
+ )
206
+
207
+ yaml_utils.validate_schedule(merged_file, full_name)
208
+
209
+ if ctx.is_non_default_branch:
210
+ yaml_utils.set_git_clone_branch(merged_file, ctx.reference)
211
+
212
+ if ctx.enable_schedule and yaml_utils.has_schedules(merged_file, full_name):
213
+ yaml_utils.set_schedules_active(merged_file, full_name, active=True)
214
+
215
+ tags = _build_tags(ctx, merged_file, full_name)
216
+ job_vars = _build_job_variables(ctx, merged_file, full_name)
217
+ logger.info("Tags: %s", tags)
218
+ logger.info("Job variables: %s", job_vars)
219
+ logger.info("Backend: %s", ctx.backend)
220
+
221
+ ctx.client.deploy(full_name, tags, job_vars, merged_file)
222
+ time.sleep(0.01)
223
+
224
+ # Detect flow rename: if flow_id changed post-deploy, clean up the old deployment
225
+ new_flow_ids = prefect_api.get_flow_ids_for_deployment(full_name)
226
+ new_flow_id = new_flow_ids[0] if new_flow_ids else ""
227
+ if flow_id and new_flow_id and new_flow_id != flow_id:
228
+ logger.info("Flow changed post-deploy — removing stale deployment under old flow.")
229
+ ctx.client.delete_deployment(f"{flow_name}/{full_name}")
230
+ flow_name = prefect_api.get_flow_name(new_flow_id)
231
+
232
+ if not flow_name:
233
+ logger.info("New deployment — retrieving flow name from Prefect Cloud post-deploy...")
234
+ post_ids = prefect_api.get_flow_ids_for_deployment(full_name)
235
+ new_flow_id = post_ids[0] if post_ids else ""
236
+ flow_name = prefect_api.get_flow_name(new_flow_id) if new_flow_id else None
237
+
238
+ # If duplicates existed under other flows and enforcement is on,
239
+ # clean up everything except the deployment matching the current flow.
240
+ if ctx.enforce_unique_deployment_names and flow_name and new_flow_id:
241
+ _cleanup_duplicate_deployments(ctx, full_name, new_flow_id, flow_name)
242
+
243
+ if flow_name:
244
+ _handle_schedules(ctx, merged_file, full_name, flow_name)
245
+ else:
246
+ logger.warning("Could not resolve flow name post-deploy — skipping schedule management.")
247
+
248
+ finally:
249
+ merged_file.unlink(missing_ok=True)
250
+ time.sleep(0.01)
@@ -0,0 +1,139 @@
1
+ """Load and normalize Prefect deployment configs from local filesystem or git refs."""
2
+
3
+ import json
4
+ import logging
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Keys excluded from deployment comparison — they don't affect runtime behaviour
14
+ _IGNORED_KEYS = frozenset({"pull"})
15
+
16
+
17
+ def normalize_deployment(deployment: dict) -> dict:
18
+ """Return deployment config with non-comparable keys removed."""
19
+ return {k: v for k, v in deployment.items() if k not in _IGNORED_KEYS}
20
+
21
+
22
+ def get_deployments_from_source(
23
+ source: str,
24
+ source_name: str,
25
+ deployments_dir: str = "deployments",
26
+ ) -> dict[str, dict]:
27
+ """Load all deployments from a git ref or the local filesystem.
28
+
29
+ Parameters
30
+ ----------
31
+ source:
32
+ Git reference (e.g. "origin/main", "HEAD~1") or "local" for the
33
+ working-tree filesystem.
34
+ source_name:
35
+ Human-readable label used in log messages.
36
+ deployments_dir:
37
+ Path to the deployments directory.
38
+
39
+ Returns
40
+ -------
41
+ dict mapping deployment name → normalised config dict.
42
+ """
43
+ logger.info("Getting deployments in %s...", source_name)
44
+ deployments: dict[str, dict] = {}
45
+ is_local = source == "local"
46
+ base_file_path = f"{deployments_dir}/prefect_base.yaml"
47
+
48
+ try:
49
+ # --- load prefect_base.yaml ---
50
+ try:
51
+ if is_local:
52
+ base_path = Path(base_file_path)
53
+ base_content = base_path.read_text() if base_path.exists() else ""
54
+ else:
55
+ base_content = subprocess.check_output(
56
+ ["git", "show", f"{source}:{base_file_path}"], # noqa: S607
57
+ ).decode()
58
+ except (subprocess.CalledProcessError, FileNotFoundError):
59
+ logger.warning("prefect_base.yaml not found in %s", source_name)
60
+ base_content = ""
61
+
62
+ # --- enumerate deployment YAML files ---
63
+ if is_local:
64
+ deployment_dir = Path(deployments_dir)
65
+ if not deployment_dir.exists():
66
+ logger.info("No %s folder found in %s", deployments_dir, source_name)
67
+ return deployments
68
+ yaml_files = [
69
+ str(f.relative_to("."))
70
+ for f in deployment_dir.rglob("*.yaml")
71
+ if f.name != "prefect_base.yaml"
72
+ ]
73
+ else:
74
+ raw = subprocess.check_output(
75
+ ["git", "ls-tree", "-r", "--name-only", source, f"{deployments_dir}/"], # noqa: S607
76
+ stderr=subprocess.DEVNULL,
77
+ ).decode().strip().split("\n")
78
+ yaml_files = [
79
+ f for f in raw
80
+ if f
81
+ and f.endswith(".yaml")
82
+ and f.startswith(f"{deployments_dir}/")
83
+ and "prefect_base.yaml" not in f
84
+ ]
85
+
86
+ logger.info("Found %d YAML files in %s folder", len(yaml_files), deployments_dir)
87
+
88
+ # --- parse each file ---
89
+ for yaml_file in yaml_files:
90
+ try:
91
+ if is_local:
92
+ deployment_content = Path(yaml_file).read_text()
93
+ else:
94
+ deployment_content = subprocess.check_output(
95
+ ["git", "show", f"{source}:{yaml_file}"], # noqa: S607
96
+ ).decode()
97
+
98
+ parsed = yaml.safe_load(base_content + "\n" + deployment_content)
99
+
100
+ if isinstance(parsed, dict) and "deployments" in parsed:
101
+ deployment_list = parsed["deployments"]
102
+ elif isinstance(parsed, list):
103
+ deployment_list = parsed
104
+ else:
105
+ deployment_list = None
106
+
107
+ if not isinstance(deployment_list, list):
108
+ continue
109
+
110
+ for d in deployment_list:
111
+ if not isinstance(d, dict):
112
+ logger.error(
113
+ "Invalid deployment entry in %s: expected dict, got %s: %s",
114
+ yaml_file, type(d).__name__, d,
115
+ )
116
+ sys.exit(1)
117
+ name = d.get("name")
118
+ if not isinstance(name, str) or not name:
119
+ logger.error(
120
+ "Deployment in %s is missing a valid 'name' field: %s",
121
+ yaml_file, d,
122
+ )
123
+ sys.exit(1)
124
+ deployments[name] = normalize_deployment(d)
125
+
126
+ logger.debug("Loaded %d deployments from %s", len(deployment_list), yaml_file)
127
+
128
+ except yaml.YAMLError as exc:
129
+ logger.error("Invalid YAML syntax in %s: %s", yaml_file, exc)
130
+ sys.exit(1)
131
+ except (subprocess.CalledProcessError, OSError) as exc:
132
+ logger.warning("Failed to load deployments from %s: %s", yaml_file, exc)
133
+ continue
134
+
135
+ except subprocess.CalledProcessError:
136
+ logger.info("No %s folder found in %s", deployments_dir, source_name)
137
+
138
+ logger.debug("Deployments in %s:\n%s", source_name, json.dumps(deployments, indent=2))
139
+ return deployments
@@ -0,0 +1,110 @@
1
+ """Retrieve all deployments added, modified, or removed in the current branch."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ from .deployment_loader import get_deployments_from_source
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def get_base_branch_deployments(
15
+ base_branch: str,
16
+ deployments_dir: str = "deployments",
17
+ ) -> dict[str, dict]:
18
+ """Get all deployments defined in the base branch."""
19
+ try:
20
+ subprocess.run(
21
+ ["git", "fetch", "origin", base_branch], # noqa: S607
22
+ check=True,
23
+ stderr=subprocess.DEVNULL,
24
+ )
25
+ except subprocess.CalledProcessError:
26
+ logger.warning("Failed to fetch base branch '%s'", base_branch)
27
+ return get_deployments_from_source(
28
+ f"origin/{base_branch}",
29
+ f"the base branch '{base_branch}'",
30
+ deployments_dir,
31
+ )
32
+
33
+
34
+ def get_pr_branch_deployments(deployments_dir: str = "deployments") -> dict[str, dict]:
35
+ """Get all deployments defined in the current branch (local filesystem)."""
36
+ return get_deployments_from_source("local", "the current branch", deployments_dir)
37
+
38
+
39
+ def get_previous_commit_deployments(deployments_dir: str = "deployments") -> dict[str, dict]:
40
+ """Get all deployments defined in the previous commit (HEAD~1)."""
41
+ result = subprocess.run(
42
+ ["git", "rev-parse", "--verify", "HEAD~1"],
43
+ capture_output=True,
44
+ )
45
+ if result.returncode != 0:
46
+ logger.warning("HEAD~1 does not exist (possibly initial commit). Returning empty.")
47
+ return {}
48
+ return get_deployments_from_source("HEAD~1", "the previous commit", deployments_dir)
49
+
50
+
51
+ def main() -> None:
52
+ logging.basicConfig(level=logging.INFO)
53
+
54
+ parser = argparse.ArgumentParser()
55
+ parser.add_argument(
56
+ "--modified-by",
57
+ choices=["pull_request_target", "pull_request", "push"],
58
+ help="GitHub Actions event type driving this run.",
59
+ )
60
+ parser.add_argument(
61
+ "--base-ref",
62
+ required=True,
63
+ help="Base branch name (e.g. master, main, rc_3.3.1).",
64
+ )
65
+ parser.add_argument(
66
+ "--deployments-dir",
67
+ default="deployments",
68
+ help="Path to the deployments directory (default: deployments).",
69
+ )
70
+ args = parser.parse_args()
71
+
72
+ is_commit_compare = args.modified_by in ("pull_request_target", "push")
73
+
74
+ previous = (
75
+ get_previous_commit_deployments(args.deployments_dir)
76
+ if is_commit_compare
77
+ else get_base_branch_deployments(args.base_ref, args.deployments_dir)
78
+ )
79
+ current = (
80
+ get_base_branch_deployments(args.base_ref, args.deployments_dir)
81
+ if is_commit_compare
82
+ else get_pr_branch_deployments(args.deployments_dir)
83
+ )
84
+
85
+ new_or_modified = [
86
+ name for name, cfg in current.items()
87
+ if name not in previous or cfg != previous[name]
88
+ ]
89
+ removed = [name for name in previous if name not in current]
90
+
91
+ if not new_or_modified and not removed:
92
+ logger.info("No new, modified, or removed deployments found.")
93
+ sys.exit(0)
94
+
95
+ all_changed = ",".join(new_or_modified + removed)
96
+ logger.info("Found the following new, modified, or removed deployments: '%s'.", all_changed)
97
+ logger.info("New or modified: '%s'.", ",".join(new_or_modified))
98
+ logger.info("Removed: '%s'.", ",".join(removed))
99
+ print(all_changed)
100
+
101
+ github_env = os.getenv("GITHUB_ENV")
102
+ if os.getenv("GITHUB_ACTION") and github_env:
103
+ with open(github_env, "a") as f:
104
+ f.write(f"DEPLOYMENT_NAMES={all_changed}\n")
105
+ f.write(f"NEW_OR_MODIFIED_DEPLOYMENT_NAMES={','.join(new_or_modified)}\n")
106
+ f.write(f"REMOVED_DEPLOYMENT_NAMES={','.join(removed)}\n")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()