hyperi-ci 1.0.18__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 (43) hide show
  1. hyperi_ci/__init__.py +11 -0
  2. hyperi_ci/cli.py +264 -0
  3. hyperi_ci/common.py +294 -0
  4. hyperi_ci/config/defaults.yaml +256 -0
  5. hyperi_ci/config/org.yaml +46 -0
  6. hyperi_ci/config.py +266 -0
  7. hyperi_ci/detect.py +95 -0
  8. hyperi_ci/dispatch.py +272 -0
  9. hyperi_ci/gh.py +133 -0
  10. hyperi_ci/init.py +563 -0
  11. hyperi_ci/languages/__init__.py +6 -0
  12. hyperi_ci/languages/golang/__init__.py +6 -0
  13. hyperi_ci/languages/golang/build.py +234 -0
  14. hyperi_ci/languages/golang/publish.py +231 -0
  15. hyperi_ci/languages/golang/quality.py +101 -0
  16. hyperi_ci/languages/golang/test.py +59 -0
  17. hyperi_ci/languages/python/__init__.py +6 -0
  18. hyperi_ci/languages/python/build.py +56 -0
  19. hyperi_ci/languages/python/publish.py +122 -0
  20. hyperi_ci/languages/python/quality.py +170 -0
  21. hyperi_ci/languages/python/test.py +96 -0
  22. hyperi_ci/languages/rust/__init__.py +6 -0
  23. hyperi_ci/languages/rust/build.py +924 -0
  24. hyperi_ci/languages/rust/publish.py +245 -0
  25. hyperi_ci/languages/rust/quality.py +133 -0
  26. hyperi_ci/languages/rust/test.py +177 -0
  27. hyperi_ci/languages/typescript/__init__.py +6 -0
  28. hyperi_ci/languages/typescript/build.py +37 -0
  29. hyperi_ci/languages/typescript/publish.py +136 -0
  30. hyperi_ci/languages/typescript/quality.py +147 -0
  31. hyperi_ci/languages/typescript/test.py +79 -0
  32. hyperi_ci/logs.py +238 -0
  33. hyperi_ci/migrate.py +553 -0
  34. hyperi_ci/publish/__init__.py +6 -0
  35. hyperi_ci/quality/__init__.py +7 -0
  36. hyperi_ci/quality/gitleaks.py +155 -0
  37. hyperi_ci/trigger.py +107 -0
  38. hyperi_ci/watch.py +171 -0
  39. hyperi_ci-1.0.18.dist-info/METADATA +9 -0
  40. hyperi_ci-1.0.18.dist-info/RECORD +43 -0
  41. hyperi_ci-1.0.18.dist-info/WHEEL +4 -0
  42. hyperi_ci-1.0.18.dist-info/entry_points.txt +2 -0
  43. hyperi_ci-1.0.18.dist-info/licenses/LICENSE +36 -0
hyperi_ci/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ # Project: HyperI CI
2
+ # File: src/hyperi_ci/__init__.py
3
+ # Purpose: Package root for hyperi-ci CLI tool
4
+ #
5
+ # License: Proprietary — HYPERI PTY LIMITED
6
+ # Copyright: (c) 2026 HYPERI PTY LIMITED
7
+ """HyperI CI/CD CLI tool — multi-language build, test, and publish automation."""
8
+
9
+ from importlib.metadata import version
10
+
11
+ __version__ = version("hyperi-ci")
hyperi_ci/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ # Project: HyperI CI
2
+ # File: src/hyperi_ci/cli.py
3
+ # Purpose: CLI entry point for hyperi-ci tool (Typer via hyperi-pylib)
4
+ #
5
+ # License: Proprietary — HYPERI PTY LIMITED
6
+ # Copyright: (c) 2026 HYPERI PTY LIMITED
7
+ """CLI entry point for HyperI CI.
8
+
9
+ Usage:
10
+ hyperi-ci run <stage> Run a CI stage (setup, quality, test, build, publish)
11
+ hyperi-ci init Initialise project (config, Makefile, workflow)
12
+ hyperi-ci detect Detect project language
13
+ hyperi-ci config Show merged configuration
14
+ hyperi-ci trigger Trigger a GitHub Actions workflow run
15
+ hyperi-ci watch [RUN_ID] Watch a GitHub Actions run to completion
16
+ hyperi-ci logs [RUN_ID] Fetch and filter GitHub Actions run logs
17
+ hyperi-ci --version Show version
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Annotated
26
+
27
+ import typer
28
+
29
+ from hyperi_ci import __version__
30
+ from hyperi_ci.config import load_config
31
+ from hyperi_ci.detect import detect_language
32
+ from hyperi_ci.dispatch import VALID_STAGES, run_stage
33
+
34
+ app = typer.Typer(
35
+ name="hyperi-ci",
36
+ help="HyperI CI — polyglot CI/CD tool",
37
+ no_args_is_help=True,
38
+ )
39
+
40
+
41
+ def _version_callback(value: bool) -> None:
42
+ if value:
43
+ typer.echo(f"hyperi-ci {__version__}")
44
+ raise typer.Exit()
45
+
46
+
47
+ @app.callback()
48
+ def _main(
49
+ version: Annotated[
50
+ bool,
51
+ typer.Option(
52
+ "--version",
53
+ "-V",
54
+ help="Show version and exit",
55
+ callback=_version_callback,
56
+ is_eager=True,
57
+ ),
58
+ ] = False,
59
+ ) -> None:
60
+ """HyperI CI — polyglot CI/CD tool."""
61
+
62
+
63
+ @app.command()
64
+ def run(
65
+ stage: Annotated[str, typer.Argument(help="Stage to run")],
66
+ project_dir: Annotated[
67
+ str | None,
68
+ typer.Option("--project-dir", "-C", help="Project root directory"),
69
+ ] = None,
70
+ ) -> None:
71
+ """Run a CI stage (setup, quality, test, build, publish)."""
72
+ if stage not in VALID_STAGES:
73
+ typer.echo(f"Invalid stage: {stage}", err=True)
74
+ typer.echo(f"Valid stages: {', '.join(VALID_STAGES)}", err=True)
75
+ raise typer.Exit(1)
76
+
77
+ dir_path = Path(project_dir) if project_dir else None
78
+ rc = run_stage(stage, project_dir=dir_path)
79
+ raise typer.Exit(rc)
80
+
81
+
82
+ @app.command()
83
+ def init(
84
+ project_dir: Annotated[
85
+ str | None,
86
+ typer.Option("--project-dir", "-C", help="Project root directory"),
87
+ ] = None,
88
+ language: Annotated[
89
+ str | None,
90
+ typer.Option("--language", "-l", help="Override detected language"),
91
+ ] = None,
92
+ force: Annotated[
93
+ bool,
94
+ typer.Option("--force", "-f", help="Overwrite existing files"),
95
+ ] = False,
96
+ ) -> None:
97
+ """Initialise a project for hyperi-ci (generates config, Makefile, workflow)."""
98
+ from hyperi_ci.init import init_project
99
+
100
+ dir_path = Path(project_dir) if project_dir else Path.cwd()
101
+ rc = init_project(dir_path, language=language, force=force)
102
+ raise typer.Exit(rc)
103
+
104
+
105
+ @app.command()
106
+ def detect(
107
+ project_dir: Annotated[
108
+ str | None,
109
+ typer.Option("--project-dir", "-C", help="Project root directory"),
110
+ ] = None,
111
+ ) -> None:
112
+ """Detect project language."""
113
+ dir_path = Path(project_dir) if project_dir else None
114
+ language = detect_language(dir_path)
115
+ if language:
116
+ typer.echo(language)
117
+ else:
118
+ typer.echo("unknown", err=True)
119
+ raise typer.Exit(1)
120
+
121
+
122
+ @app.command()
123
+ def config(
124
+ project_dir: Annotated[
125
+ str | None,
126
+ typer.Option("--project-dir", "-C", help="Project root directory"),
127
+ ] = None,
128
+ ) -> None:
129
+ """Show merged configuration."""
130
+ dir_path = Path(project_dir) if project_dir else None
131
+ cfg = load_config(reload=True, project_dir=dir_path)
132
+ typer.echo(json.dumps(cfg._raw, indent=2, default=str))
133
+
134
+
135
+ @app.command()
136
+ def migrate(
137
+ project_dir: Annotated[
138
+ str | None,
139
+ typer.Option("--project-dir", "-C", help="Project root directory"),
140
+ ] = None,
141
+ language: Annotated[
142
+ str | None,
143
+ typer.Option("--language", "-l", help="Override detected language"),
144
+ ] = None,
145
+ dry_run: Annotated[
146
+ bool,
147
+ typer.Option("--dry-run", "-n", help="Show what would be done"),
148
+ ] = False,
149
+ ) -> None:
150
+ """Migrate a project from old ci/ submodule to hyperi-ci."""
151
+ from hyperi_ci.migrate import migrate_project
152
+
153
+ dir_path = Path(project_dir) if project_dir else Path.cwd()
154
+ rc = migrate_project(dir_path, language=language, dry_run=dry_run)
155
+ raise typer.Exit(rc)
156
+
157
+
158
+ @app.command()
159
+ def trigger(
160
+ workflow: Annotated[
161
+ str,
162
+ typer.Option("--workflow", "-w", help="Workflow filename"),
163
+ ] = "ci.yml",
164
+ ref: Annotated[
165
+ str | None,
166
+ typer.Option("--ref", "-r", help="Branch or tag to run on"),
167
+ ] = None,
168
+ watch_run: Annotated[
169
+ bool,
170
+ typer.Option("--watch", help="Watch run to completion after triggering"),
171
+ ] = False,
172
+ timeout: Annotated[
173
+ int,
174
+ typer.Option("--timeout", "-t", help="Timeout in seconds"),
175
+ ] = 1800,
176
+ interval: Annotated[
177
+ int,
178
+ typer.Option("--interval", "-i", help="Poll interval in seconds"),
179
+ ] = 30,
180
+ ) -> None:
181
+ """Trigger a GitHub Actions workflow run."""
182
+ from hyperi_ci.trigger import trigger_workflow
183
+
184
+ rc = trigger_workflow(
185
+ workflow=workflow,
186
+ ref=ref,
187
+ watch=watch_run,
188
+ timeout=timeout,
189
+ interval=interval,
190
+ )
191
+ raise typer.Exit(rc)
192
+
193
+
194
+ @app.command()
195
+ def watch(
196
+ run_id: Annotated[
197
+ str | None,
198
+ typer.Argument(help="Run ID (auto-detects latest if omitted)"),
199
+ ] = None,
200
+ timeout: Annotated[
201
+ int,
202
+ typer.Option("--timeout", "-t", help="Timeout in seconds"),
203
+ ] = 1800,
204
+ interval: Annotated[
205
+ int,
206
+ typer.Option("--interval", "-i", help="Initial poll interval in seconds"),
207
+ ] = 30,
208
+ ) -> None:
209
+ """Watch a GitHub Actions run to completion."""
210
+ from hyperi_ci.watch import watch_run
211
+
212
+ rc = watch_run(run_id=run_id, timeout=timeout, interval=interval)
213
+ raise typer.Exit(rc)
214
+
215
+
216
+ @app.command()
217
+ def logs(
218
+ run_id: Annotated[
219
+ str | None,
220
+ typer.Argument(help="Run ID (auto-detects latest if omitted)"),
221
+ ] = None,
222
+ job: Annotated[
223
+ str | None,
224
+ typer.Option("--job", "-j", help="Filter by job name (substring)"),
225
+ ] = None,
226
+ step: Annotated[
227
+ str | None,
228
+ typer.Option("--step", "-s", help="Filter by step name (substring)"),
229
+ ] = None,
230
+ grep: Annotated[
231
+ str | None,
232
+ typer.Option("--grep", "-g", help="Filter lines by pattern"),
233
+ ] = None,
234
+ tail: Annotated[
235
+ int | None,
236
+ typer.Option("--tail", "-n", help="Show last N lines"),
237
+ ] = None,
238
+ failed: Annotated[
239
+ bool,
240
+ typer.Option("--failed", help="Show only failed job logs"),
241
+ ] = False,
242
+ ) -> None:
243
+ """Fetch and filter GitHub Actions run logs."""
244
+ from hyperi_ci.logs import fetch_logs
245
+
246
+ rc = fetch_logs(
247
+ run_id=run_id,
248
+ job_filter=job,
249
+ step_filter=step,
250
+ grep_pattern=grep,
251
+ tail_lines=tail,
252
+ failed_only=failed,
253
+ )
254
+ raise typer.Exit(rc)
255
+
256
+
257
+ def main() -> int:
258
+ """CLI entry point."""
259
+ app()
260
+ return 0
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())
hyperi_ci/common.py ADDED
@@ -0,0 +1,294 @@
1
+ # Project: HyperI CI
2
+ # File: src/hyperi_ci/common.py
3
+ # Purpose: Shared utilities for CI scripts (output, subprocess, exclusions)
4
+ #
5
+ # License: Proprietary — HYPERI PTY LIMITED
6
+ # Copyright: (c) 2026 HYPERI PTY LIMITED
7
+ """Shared utilities for HyperI CI.
8
+
9
+ Uses hyperi-pylib logger for structured output with automatic environment
10
+ detection (GitHub Actions workflow commands, Solarized terminal, plain CI).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from collections.abc import Iterator
19
+ from contextlib import contextmanager
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from hyperi_pylib.logger import logger
24
+
25
+ # Initialise logger for CI use (auto-detects GH Actions, CI, terminal)
26
+ from hyperi_pylib.logger import setup as _setup_logger
27
+
28
+ _setup_logger(ci_mode=None, mask_sensitive=True)
29
+
30
+
31
+ def is_ci() -> bool:
32
+ """Detect if running in a CI/runner environment."""
33
+ return any(
34
+ os.environ.get(v)
35
+ for v in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE")
36
+ )
37
+
38
+
39
+ def is_github_actions() -> bool:
40
+ """Detect if running in GitHub Actions specifically."""
41
+ return bool(os.environ.get("GITHUB_ACTIONS"))
42
+
43
+
44
+ def is_interactive() -> bool:
45
+ """Detect if running in an interactive terminal (supports colours)."""
46
+ if not sys.stderr.isatty():
47
+ return False
48
+ term = os.environ.get("TERM", "")
49
+ if term == "dumb" or not term:
50
+ return False
51
+ if is_ci():
52
+ return False
53
+ return True
54
+
55
+
56
+ def is_macos() -> bool:
57
+ """Detect if running on macOS."""
58
+ return sys.platform == "darwin"
59
+
60
+
61
+ def is_linux() -> bool:
62
+ """Detect if running on Linux."""
63
+ return sys.platform.startswith("linux")
64
+
65
+
66
+ def info(msg: str) -> None:
67
+ """Info message — delegates to hyperi-pylib logger."""
68
+ logger.info(msg)
69
+
70
+
71
+ def success(msg: str) -> None:
72
+ """Success message — delegates to hyperi-pylib logger."""
73
+ logger.success(msg)
74
+
75
+
76
+ def warn(msg: str) -> None:
77
+ """Warning — delegates to hyperi-pylib logger."""
78
+ logger.warning(msg)
79
+
80
+
81
+ def error(msg: str) -> None:
82
+ """Error — delegates to hyperi-pylib logger."""
83
+ logger.error(msg)
84
+
85
+
86
+ def fatal(msg: str) -> None:
87
+ """Fatal error — log and exit with code 1."""
88
+ logger.critical(msg)
89
+ sys.exit(1)
90
+
91
+
92
+ @contextmanager
93
+ def group(title: str) -> Iterator[None]:
94
+ """Collapsible group in GH Actions logs. No-op elsewhere."""
95
+ if is_github_actions():
96
+ print(f"::group::{title}")
97
+ try:
98
+ yield
99
+ finally:
100
+ if is_github_actions():
101
+ print("::endgroup::")
102
+
103
+
104
+ def set_output(name: str, value: str) -> None:
105
+ """Set a GH Actions step output parameter via GITHUB_OUTPUT file."""
106
+ output_file = os.environ.get("GITHUB_OUTPUT")
107
+ if output_file:
108
+ with open(output_file, "a") as f:
109
+ f.write(f"{name}={value}\n")
110
+
111
+
112
+ def set_env(name: str, value: str) -> None:
113
+ """Set a GH Actions environment variable via GITHUB_ENV file."""
114
+ env_file = os.environ.get("GITHUB_ENV")
115
+ if env_file:
116
+ with open(env_file, "a") as f:
117
+ f.write(f"{name}={value}\n")
118
+
119
+
120
+ def mask(value: str) -> None:
121
+ """Mask a value in GH Actions logs."""
122
+ if is_github_actions():
123
+ print(f"::add-mask::{value}")
124
+
125
+
126
+ def run_cmd(
127
+ cmd: list[str],
128
+ *,
129
+ check: bool = True,
130
+ capture: bool = False,
131
+ cwd: str | Path | None = None,
132
+ env: dict[str, str] | None = None,
133
+ ) -> subprocess.CompletedProcess[str]:
134
+ """Run a subprocess with consistent error handling.
135
+
136
+ Args:
137
+ cmd: Command as list of strings.
138
+ check: Raise CalledProcessError on non-zero exit.
139
+ capture: Capture stdout/stderr instead of passing through.
140
+ cwd: Working directory.
141
+ env: Additional env vars (merged with os.environ).
142
+
143
+ Returns:
144
+ CompletedProcess with text output.
145
+ """
146
+ run_env = None
147
+ if env:
148
+ run_env = {**os.environ, **env}
149
+
150
+ return subprocess.run(
151
+ cmd,
152
+ check=check,
153
+ capture_output=capture,
154
+ text=True,
155
+ cwd=cwd,
156
+ env=run_env,
157
+ )
158
+
159
+
160
+ def verify_publish(
161
+ url: str,
162
+ *,
163
+ auth: tuple[str, str] | None = None,
164
+ max_retries: int = 5,
165
+ retry_delay: int = 10,
166
+ label: str = "",
167
+ ) -> bool:
168
+ """Verify a published artifact is reachable via HTTP HEAD with retries.
169
+
170
+ JFrog Artifactory has indexing lag — a just-published artifact may return
171
+ 404 for several seconds. This retries with delay to account for that.
172
+
173
+ Args:
174
+ url: Full URL to HEAD-check.
175
+ auth: Optional (username, password) tuple for basic auth.
176
+ max_retries: Maximum number of attempts.
177
+ retry_delay: Seconds to wait between retries.
178
+ label: Human-readable label for log messages.
179
+
180
+ Returns:
181
+ True if the artifact was found (HTTP 200), False otherwise.
182
+ """
183
+ import time
184
+
185
+ display = label or url.rsplit("/", 1)[-1]
186
+
187
+ for attempt in range(1, max_retries + 1):
188
+ cmd = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--head"]
189
+ if auth:
190
+ cmd.extend(["-u", f"{auth[0]}:{auth[1]}"])
191
+ cmd.append(url)
192
+
193
+ result = subprocess.run(cmd, capture_output=True, text=True)
194
+ http_code = result.stdout.strip()
195
+
196
+ if http_code == "200":
197
+ success(f" Verified: {display}")
198
+ return True
199
+
200
+ if attempt < max_retries:
201
+ info(
202
+ f" Attempt {attempt}/{max_retries}: {display} "
203
+ f"not found (HTTP {http_code}), retrying in {retry_delay}s..."
204
+ )
205
+ time.sleep(retry_delay)
206
+
207
+ error(f" Verification failed: {display} not found after {max_retries} attempts")
208
+ return False
209
+
210
+
211
+ # Common directories to exclude from quality checks
212
+ _COMMON_EXCLUDES = [
213
+ ".venv",
214
+ "venv",
215
+ "env",
216
+ ".env",
217
+ "virtualenv",
218
+ ".virtualenv",
219
+ "__pycache__",
220
+ ".pytest_cache",
221
+ ".mypy_cache",
222
+ ".ruff_cache",
223
+ ".hypothesis",
224
+ "*.egg-info",
225
+ ".eggs",
226
+ "dist",
227
+ "build",
228
+ "wheelhouse",
229
+ ".tox",
230
+ ".nox",
231
+ ".git",
232
+ ".github",
233
+ "node_modules",
234
+ ".npm",
235
+ ".yarn",
236
+ ".pnpm-store",
237
+ ".next",
238
+ ".nuxt",
239
+ ".output",
240
+ ".svelte-kit",
241
+ "target",
242
+ "vendor",
243
+ ".idea",
244
+ ".vscode",
245
+ ".vs",
246
+ "htmlcov",
247
+ "coverage",
248
+ ".coverage",
249
+ ".nyc_output",
250
+ "_build",
251
+ "site",
252
+ ".cache",
253
+ ".tmp",
254
+ "tmp",
255
+ ".temp",
256
+ "temp",
257
+ ]
258
+
259
+
260
+ def get_exclude_dirs(config_raw: dict[str, Any] | None = None) -> list[str]:
261
+ """Get directories to exclude from quality checks.
262
+
263
+ Combines:
264
+ 1. Git submodule paths (from .gitmodules)
265
+ 2. ci/ and ai/ (always)
266
+ 3. Common directories (.venv, node_modules, target, etc.)
267
+ 4. Custom paths from quality.exclude_paths config
268
+ """
269
+ excludes: list[str] = []
270
+
271
+ gitmodules = Path(".gitmodules")
272
+ if gitmodules.exists():
273
+ for line in gitmodules.read_text().splitlines():
274
+ if "path" in line and "=" in line:
275
+ path = line.split("=", 1)[1].strip()
276
+ if path and Path(path).is_dir():
277
+ excludes.append(path)
278
+
279
+ for submod in ("ci", "ai"):
280
+ if Path(submod).is_dir() and submod not in excludes:
281
+ excludes.append(submod)
282
+
283
+ for dirname in _COMMON_EXCLUDES:
284
+ if Path(dirname).exists() and dirname not in excludes:
285
+ excludes.append(dirname)
286
+
287
+ if config_raw:
288
+ custom = config_raw.get("quality", {}).get("exclude_paths", [])
289
+ if isinstance(custom, list):
290
+ for path in custom:
291
+ if path and Path(path).is_dir() and path not in excludes:
292
+ excludes.append(path)
293
+
294
+ return excludes