semacli 0.1.2__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.
- semacli/__init__.py +5 -0
- semacli/__main__.py +6 -0
- semacli/cli/__init__.py +17 -0
- semacli/cli/commands/__init__.py +12 -0
- semacli/cli/commands/ping.py +44 -0
- semacli/cli/commands/projects.py +62 -0
- semacli/cli/decorators.py +24 -0
- semacli/cli/handlers.py +53 -0
- semacli/core/__init__.py +1 -0
- semacli/core/client.py +127 -0
- semacli/core/config.py +111 -0
- semacli/core/exceptions.py +31 -0
- semacli/core/models.py +69 -0
- semacli/services/__init__.py +1 -0
- semacli-0.1.2.dist-info/METADATA +200 -0
- semacli-0.1.2.dist-info/RECORD +19 -0
- semacli-0.1.2.dist-info/WHEEL +4 -0
- semacli-0.1.2.dist-info/entry_points.txt +5 -0
- semacli-0.1.2.dist-info/licenses/LICENSE +21 -0
semacli/__init__.py
ADDED
semacli/__main__.py
ADDED
semacli/cli/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CLI module for semacli."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from semacli import __version__
|
|
6
|
+
|
|
7
|
+
from .commands import register_all_commands
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(version=__version__, prog_name="semacli")
|
|
12
|
+
def main() -> None:
|
|
13
|
+
"""Semaphore CLI - Manage Semaphore UI via HTTP REST API."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
register_all_commands(main)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Commands package for CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .ping import register_ping_commands
|
|
6
|
+
from .projects import register_projects_commands
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_all_commands(main_group: Any) -> None:
|
|
10
|
+
"""Register all commands with the main CLI group."""
|
|
11
|
+
register_ping_commands(main_group)
|
|
12
|
+
register_projects_commands(main_group)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Ping command for CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from semacli.core.client import SemaphoreClient
|
|
9
|
+
from semacli.core.config import load_config
|
|
10
|
+
|
|
11
|
+
from ..decorators import common_options, output_options
|
|
12
|
+
from ..handlers import OutputFormatter, handle_error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_ping_commands(main_group: Any) -> None:
|
|
16
|
+
"""Register ping commands with the main CLI group."""
|
|
17
|
+
|
|
18
|
+
@main_group.command("ping")
|
|
19
|
+
@common_options
|
|
20
|
+
@output_options
|
|
21
|
+
def ping_cmd(
|
|
22
|
+
config: str,
|
|
23
|
+
verbose: int,
|
|
24
|
+
output_json: bool,
|
|
25
|
+
quiet: bool,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Ping the Semaphore API (GET /api/ping)."""
|
|
28
|
+
try:
|
|
29
|
+
cfg = load_config(config)
|
|
30
|
+
client = SemaphoreClient(cfg, verbose=verbose)
|
|
31
|
+
|
|
32
|
+
OutputFormatter.format_verbose(f"Pinging {cfg.url}", verbose)
|
|
33
|
+
|
|
34
|
+
pong = client.ping()
|
|
35
|
+
|
|
36
|
+
if output_json:
|
|
37
|
+
click.echo(json.dumps({"ping": pong}))
|
|
38
|
+
elif quiet:
|
|
39
|
+
pass
|
|
40
|
+
else:
|
|
41
|
+
click.echo(pong)
|
|
42
|
+
|
|
43
|
+
except Exception as e:
|
|
44
|
+
handle_error(e, verbose)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Projects command for CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from semacli.core.client import SemaphoreClient
|
|
9
|
+
from semacli.core.config import load_config
|
|
10
|
+
from semacli.core.models import Project
|
|
11
|
+
|
|
12
|
+
from ..decorators import common_options, output_options
|
|
13
|
+
from ..handlers import OutputFormatter, handle_error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _emit_projects_json(projects: list[Project]) -> None:
|
|
17
|
+
output = [
|
|
18
|
+
{"id": p.id, "name": p.name, "created": p.created}
|
|
19
|
+
for p in projects
|
|
20
|
+
]
|
|
21
|
+
click.echo(json.dumps(output, indent=2))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _emit_projects_text(projects: list[Project]) -> None:
|
|
25
|
+
if not projects:
|
|
26
|
+
click.echo("No projects found")
|
|
27
|
+
return
|
|
28
|
+
for p in projects:
|
|
29
|
+
click.echo(f"{p.id:>4} {p.name}")
|
|
30
|
+
click.echo(f"\nTotal: {len(projects)} project(s)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_projects_commands(main_group: Any) -> None:
|
|
34
|
+
"""Register projects commands with the main CLI group."""
|
|
35
|
+
|
|
36
|
+
@main_group.command("projects")
|
|
37
|
+
@common_options
|
|
38
|
+
@output_options
|
|
39
|
+
def projects_cmd(
|
|
40
|
+
config: str,
|
|
41
|
+
verbose: int,
|
|
42
|
+
output_json: bool,
|
|
43
|
+
quiet: bool,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""List all Semaphore projects."""
|
|
46
|
+
try:
|
|
47
|
+
cfg = load_config(config)
|
|
48
|
+
client = SemaphoreClient(cfg, verbose=verbose)
|
|
49
|
+
|
|
50
|
+
OutputFormatter.format_verbose(f"Listing projects from {cfg.url}", verbose)
|
|
51
|
+
|
|
52
|
+
projects = client.get_projects()
|
|
53
|
+
|
|
54
|
+
if output_json:
|
|
55
|
+
_emit_projects_json(projects)
|
|
56
|
+
elif quiet:
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
_emit_projects_text(projects)
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
handle_error(e, verbose)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""CLI decorators for semacli."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def common_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
10
|
+
"""Decorator for common CLI options."""
|
|
11
|
+
func = click.option(
|
|
12
|
+
"-c", "--config", default="semacli.ini", help="Configuration file path"
|
|
13
|
+
)(func)
|
|
14
|
+
func = click.option("-v", "--verbose", count=True, help="Increase verbosity")(func)
|
|
15
|
+
|
|
16
|
+
return func
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def output_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
20
|
+
"""Decorator for output format options."""
|
|
21
|
+
func = click.option("--json", "output_json", is_flag=True, help="Output as JSON")(func)
|
|
22
|
+
func = click.option("-q", "--quiet", is_flag=True, help="Minimal output")(func)
|
|
23
|
+
|
|
24
|
+
return func
|
semacli/cli/handlers.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Error handlers and formatters for click CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import NoReturn
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from semacli.core.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
SemaphoreAPIError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_error(error: Exception, verbose: int = 0) -> NoReturn:
|
|
17
|
+
"""Handle exceptions and exit with appropriate code.
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
1 - General error
|
|
21
|
+
2 - Configuration error
|
|
22
|
+
3 - Authentication error
|
|
23
|
+
4 - API error
|
|
24
|
+
5 - Not found
|
|
25
|
+
"""
|
|
26
|
+
if verbose >= 1:
|
|
27
|
+
click.echo(f"DEBUG: {type(error).__name__}: {error}", err=True)
|
|
28
|
+
|
|
29
|
+
if isinstance(error, ConfigurationError):
|
|
30
|
+
click.echo(f"Configuration error: {error}", err=True)
|
|
31
|
+
sys.exit(2)
|
|
32
|
+
elif isinstance(error, AuthenticationError):
|
|
33
|
+
click.echo(f"Authentication error: {error}", err=True)
|
|
34
|
+
sys.exit(3)
|
|
35
|
+
elif isinstance(error, SemaphoreAPIError):
|
|
36
|
+
click.echo(f"API error: {error}", err=True)
|
|
37
|
+
sys.exit(4)
|
|
38
|
+
elif isinstance(error, NotFoundError):
|
|
39
|
+
click.echo(f"Not found: {error}", err=True)
|
|
40
|
+
sys.exit(5)
|
|
41
|
+
else:
|
|
42
|
+
click.echo(f"Error: {error}", err=True)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OutputFormatter:
|
|
47
|
+
"""Output formatters for different verbosity levels."""
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def format_verbose(message: str, verbose_level: int, min_level: int = 1) -> None:
|
|
51
|
+
"""Print message if verbosity is high enough."""
|
|
52
|
+
if verbose_level >= min_level:
|
|
53
|
+
click.echo(f"DEBUG: {message}", err=True)
|
semacli/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core module for semacli."""
|
semacli/core/client.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Semaphore HTTP API client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import ssl
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import SemaphoreConfig
|
|
11
|
+
from .exceptions import AuthenticationError, NotFoundError, SemaphoreAPIError
|
|
12
|
+
from .models import Project
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SemaphoreClient:
|
|
16
|
+
"""HTTP client for Semaphore UI REST API."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: SemaphoreConfig, verbose: int = 0) -> None:
|
|
19
|
+
self.config = config
|
|
20
|
+
self.verbose = verbose
|
|
21
|
+
self._opener: urllib.request.OpenerDirector | None = None
|
|
22
|
+
|
|
23
|
+
def _get_opener(self) -> urllib.request.OpenerDirector:
|
|
24
|
+
"""Get or create HTTP opener with SSL handling."""
|
|
25
|
+
if self._opener is None:
|
|
26
|
+
handlers: list[urllib.request.BaseHandler] = []
|
|
27
|
+
|
|
28
|
+
if not self.config.verify_ssl:
|
|
29
|
+
# Opt-in insecure mode for self-signed certs.
|
|
30
|
+
ssl_context = ssl.create_default_context()
|
|
31
|
+
ssl_context.check_hostname = False # NOSONAR: explicit user opt-in via verify_ssl=False
|
|
32
|
+
ssl_context.verify_mode = ssl.CERT_NONE # NOSONAR: explicit user opt-in via verify_ssl=False
|
|
33
|
+
handlers.append(urllib.request.HTTPSHandler(context=ssl_context))
|
|
34
|
+
|
|
35
|
+
self._opener = urllib.request.build_opener(*handlers)
|
|
36
|
+
|
|
37
|
+
return self._opener
|
|
38
|
+
|
|
39
|
+
def _build_request(
|
|
40
|
+
self,
|
|
41
|
+
endpoint: str,
|
|
42
|
+
method: str = "GET",
|
|
43
|
+
params: dict[str, str] | None = None,
|
|
44
|
+
body: dict[str, Any] | None = None,
|
|
45
|
+
require_auth: bool = True,
|
|
46
|
+
) -> urllib.request.Request:
|
|
47
|
+
url = f"{self.config.url}/api/{endpoint.lstrip('/')}"
|
|
48
|
+
if params:
|
|
49
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
50
|
+
|
|
51
|
+
if self.verbose >= 2:
|
|
52
|
+
print(f"DEBUG: {method} {url}")
|
|
53
|
+
|
|
54
|
+
data: bytes | None = None
|
|
55
|
+
request = urllib.request.Request(url, method=method)
|
|
56
|
+
|
|
57
|
+
if body is not None:
|
|
58
|
+
data = json.dumps(body).encode("utf-8")
|
|
59
|
+
request.add_header("Content-Type", "application/json")
|
|
60
|
+
request.data = data
|
|
61
|
+
|
|
62
|
+
if require_auth:
|
|
63
|
+
if not self.config.bearer_token:
|
|
64
|
+
raise AuthenticationError("No bearer_token configured")
|
|
65
|
+
request.add_header("Authorization", f"Bearer {self.config.bearer_token}")
|
|
66
|
+
|
|
67
|
+
request.add_header("Accept", "application/json")
|
|
68
|
+
return request
|
|
69
|
+
|
|
70
|
+
def _request(
|
|
71
|
+
self,
|
|
72
|
+
endpoint: str,
|
|
73
|
+
method: str = "GET",
|
|
74
|
+
params: dict[str, str] | None = None,
|
|
75
|
+
body: dict[str, Any] | None = None,
|
|
76
|
+
require_auth: bool = True,
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Make HTTP request to Semaphore API and return parsed JSON (or raw text)."""
|
|
79
|
+
request = self._build_request(endpoint, method, params, body, require_auth)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
response = self._get_opener().open(request, timeout=self.config.timeout)
|
|
83
|
+
content = response.read().decode("utf-8")
|
|
84
|
+
|
|
85
|
+
if self.verbose >= 3:
|
|
86
|
+
print(f"DEBUG: Response: {content[:500]}")
|
|
87
|
+
|
|
88
|
+
if not content:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(content)
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
return content
|
|
94
|
+
|
|
95
|
+
except urllib.error.HTTPError as e:
|
|
96
|
+
if e.code in (401, 403):
|
|
97
|
+
raise AuthenticationError(f"HTTP {e.code}: {e.reason}") from e
|
|
98
|
+
if e.code == 404:
|
|
99
|
+
raise NotFoundError(f"HTTP 404: {endpoint}") from e
|
|
100
|
+
raise SemaphoreAPIError(f"HTTP {e.code}: {e.reason}") from e
|
|
101
|
+
except urllib.error.URLError as e:
|
|
102
|
+
raise SemaphoreAPIError(f"Connection error: {e.reason}") from e
|
|
103
|
+
|
|
104
|
+
def ping(self) -> str:
|
|
105
|
+
"""GET /api/ping — does not require authentication."""
|
|
106
|
+
result = self._request("ping", require_auth=False)
|
|
107
|
+
if isinstance(result, str):
|
|
108
|
+
return result.strip()
|
|
109
|
+
return str(result)
|
|
110
|
+
|
|
111
|
+
def get_projects(self) -> list[Project]:
|
|
112
|
+
"""GET /api/projects — list all projects visible to the token."""
|
|
113
|
+
data = self._request("projects")
|
|
114
|
+
if not isinstance(data, list):
|
|
115
|
+
raise SemaphoreAPIError("Unexpected response format for /projects")
|
|
116
|
+
return [self._parse_project(item) for item in data]
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _parse_project(data: dict[str, Any]) -> Project:
|
|
120
|
+
return Project(
|
|
121
|
+
id=int(data.get("id", 0)),
|
|
122
|
+
name=str(data.get("name", "")),
|
|
123
|
+
created=str(data.get("created", "")),
|
|
124
|
+
alert=bool(data.get("alert", False)),
|
|
125
|
+
alert_chat=str(data.get("alert_chat", "")),
|
|
126
|
+
max_parallel_tasks=int(data.get("max_parallel_tasks", 0)),
|
|
127
|
+
)
|
semacli/core/config.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Configuration management for semacli."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .exceptions import ConfigurationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SemaphoreConfig:
|
|
13
|
+
"""Semaphore connection configuration."""
|
|
14
|
+
|
|
15
|
+
url: str
|
|
16
|
+
bearer_token: str | None = None
|
|
17
|
+
project: int | None = None
|
|
18
|
+
timeout: int = 30
|
|
19
|
+
verify_ssl: bool = True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(config_path: str = "semacli.ini") -> SemaphoreConfig:
|
|
23
|
+
"""Load configuration from file.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ConfigurationError: If configuration is invalid
|
|
27
|
+
"""
|
|
28
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
29
|
+
|
|
30
|
+
config_file = _find_config_file(config_path)
|
|
31
|
+
|
|
32
|
+
if not config_file or not os.path.exists(config_file):
|
|
33
|
+
raise ConfigurationError(f"Configuration file not found: {config_path}")
|
|
34
|
+
|
|
35
|
+
config.read(config_file)
|
|
36
|
+
|
|
37
|
+
return _parse_config(config)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_config_file(config_path: str) -> str | None:
|
|
41
|
+
"""Find configuration file in standard locations.
|
|
42
|
+
|
|
43
|
+
Search order:
|
|
44
|
+
1. Absolute path (if provided)
|
|
45
|
+
2. Current directory
|
|
46
|
+
3. User home directory (~/.semacli.ini)
|
|
47
|
+
4. /usr/local/etc/semacli.ini
|
|
48
|
+
"""
|
|
49
|
+
if os.path.isabs(config_path):
|
|
50
|
+
return config_path
|
|
51
|
+
|
|
52
|
+
current_dir = Path.cwd() / config_path
|
|
53
|
+
if current_dir.exists():
|
|
54
|
+
return str(current_dir)
|
|
55
|
+
|
|
56
|
+
home_dir = Path.home() / f".{config_path}"
|
|
57
|
+
if home_dir.exists():
|
|
58
|
+
return str(home_dir)
|
|
59
|
+
|
|
60
|
+
system_config = Path("/usr/local/etc") / config_path
|
|
61
|
+
if system_config.exists():
|
|
62
|
+
return str(system_config)
|
|
63
|
+
|
|
64
|
+
return config_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_config(config: configparser.ConfigParser) -> SemaphoreConfig:
|
|
68
|
+
"""Parse configuration into SemaphoreConfig object."""
|
|
69
|
+
if "semaphore" not in config:
|
|
70
|
+
raise ConfigurationError("Missing [semaphore] section in configuration")
|
|
71
|
+
|
|
72
|
+
sema_section = config["semaphore"]
|
|
73
|
+
|
|
74
|
+
url = sema_section.get("url")
|
|
75
|
+
if not url:
|
|
76
|
+
raise ConfigurationError("Missing 'url' in [semaphore] section")
|
|
77
|
+
|
|
78
|
+
project_raw = sema_section.get("project")
|
|
79
|
+
project = int(project_raw) if project_raw else None
|
|
80
|
+
|
|
81
|
+
bearer_token: str | None = None
|
|
82
|
+
|
|
83
|
+
if "auth" in config:
|
|
84
|
+
auth_section = config["auth"]
|
|
85
|
+
method = auth_section.get("method", "bearer_token")
|
|
86
|
+
|
|
87
|
+
if method == "bearer_token":
|
|
88
|
+
bearer_token = auth_section.get("bearer_token")
|
|
89
|
+
elif method == "env_var":
|
|
90
|
+
env_var = auth_section.get("env_var", "SEMAPHORE_TOKEN")
|
|
91
|
+
bearer_token = os.environ.get(env_var)
|
|
92
|
+
else:
|
|
93
|
+
raise ConfigurationError(f"Unknown auth method: {method}")
|
|
94
|
+
else:
|
|
95
|
+
bearer_token = sema_section.get("bearer_token")
|
|
96
|
+
|
|
97
|
+
timeout = 30
|
|
98
|
+
verify_ssl = True
|
|
99
|
+
|
|
100
|
+
if "settings" in config:
|
|
101
|
+
settings_section = config["settings"]
|
|
102
|
+
timeout = settings_section.getint("timeout", 30)
|
|
103
|
+
verify_ssl = settings_section.getboolean("verify_ssl", True)
|
|
104
|
+
|
|
105
|
+
return SemaphoreConfig(
|
|
106
|
+
url=url.rstrip("/"),
|
|
107
|
+
bearer_token=bearer_token,
|
|
108
|
+
project=project,
|
|
109
|
+
timeout=timeout,
|
|
110
|
+
verify_ssl=verify_ssl,
|
|
111
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Custom exceptions for semacli."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SemaCliError(Exception):
|
|
5
|
+
"""Base exception for semacli."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(SemaCliError):
|
|
11
|
+
"""Raised when there's a configuration error."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(SemaCliError):
|
|
17
|
+
"""Raised when there's an authentication error."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SemaphoreAPIError(SemaCliError):
|
|
23
|
+
"""Raised when there's an error with the Semaphore API."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(SemaCliError):
|
|
29
|
+
"""Raised when a resource is not found."""
|
|
30
|
+
|
|
31
|
+
pass
|
semacli/core/models.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Data models for semacli."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Project:
|
|
8
|
+
"""A Semaphore project."""
|
|
9
|
+
|
|
10
|
+
id: int
|
|
11
|
+
name: str
|
|
12
|
+
created: str = ""
|
|
13
|
+
alert: bool = False
|
|
14
|
+
alert_chat: str = ""
|
|
15
|
+
max_parallel_tasks: int = 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Template:
|
|
20
|
+
"""A Semaphore task template."""
|
|
21
|
+
|
|
22
|
+
id: int
|
|
23
|
+
project_id: int
|
|
24
|
+
name: str
|
|
25
|
+
playbook: str = ""
|
|
26
|
+
inventory_id: int = 0
|
|
27
|
+
repository_id: int = 0
|
|
28
|
+
environment_id: int = 0
|
|
29
|
+
description: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Task:
|
|
34
|
+
"""A Semaphore task (a run of a template)."""
|
|
35
|
+
|
|
36
|
+
id: int
|
|
37
|
+
template_id: int
|
|
38
|
+
status: str = ""
|
|
39
|
+
debug: bool = False
|
|
40
|
+
dry_run: bool = False
|
|
41
|
+
playbook: str = ""
|
|
42
|
+
environment: str = ""
|
|
43
|
+
created: str = ""
|
|
44
|
+
start: str = ""
|
|
45
|
+
end: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Inventory:
|
|
50
|
+
"""A Semaphore inventory."""
|
|
51
|
+
|
|
52
|
+
id: int
|
|
53
|
+
project_id: int
|
|
54
|
+
name: str
|
|
55
|
+
type: str = ""
|
|
56
|
+
inventory: str = ""
|
|
57
|
+
ssh_key_id: int = 0
|
|
58
|
+
become_key_id: int = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Environment:
|
|
63
|
+
"""A Semaphore environment (extra vars + secrets)."""
|
|
64
|
+
|
|
65
|
+
id: int
|
|
66
|
+
project_id: int
|
|
67
|
+
name: str
|
|
68
|
+
password: str = ""
|
|
69
|
+
json: str = ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Services module for semacli."""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: semacli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A CLI tool to manage Semaphore UI (ansible-semaphore) via HTTP REST API
|
|
5
|
+
Keywords: semaphore,ansible,cli,api,devops
|
|
6
|
+
Author-Email: lduchosal <lduchosal@github.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: System Administrators
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: System :: Systems Administration
|
|
18
|
+
Project-URL: Homepage, https://github.com/lduchosal/semacli
|
|
19
|
+
Project-URL: Bug Reports, https://github.com/lduchosal/semacli/issues
|
|
20
|
+
Project-URL: Source, https://github.com/lduchosal/semacli
|
|
21
|
+
Project-URL: Documentation, https://github.com/lduchosal/semacli/blob/main/README.md
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: click<9.0,>=8.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# semacli
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/semacli/)
|
|
29
|
+
[](https://pypi.org/project/semacli/)
|
|
30
|
+
[](https://opensource.org/licenses/MIT)
|
|
31
|
+
[](https://github.com/lduchosal/semacli/actions/workflows/python-package.yml)
|
|
32
|
+
[](https://github.com/lduchosal/semacli/actions/workflows/python-publish.yml)
|
|
33
|
+
[](https://codecov.io/gh/lduchosal/semacli)
|
|
34
|
+
[](./interrogate_badge.svg)
|
|
35
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
36
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
37
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
38
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
39
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
40
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
41
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
42
|
+
[](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
|
|
43
|
+
|
|
44
|
+
A CLI tool to manage [Semaphore UI](https://semaphoreui.com) (ansible-semaphore) via its HTTP REST API.
|
|
45
|
+
|
|
46
|
+
Designed for LLM/agent and automation use — deterministic commands, JSON output, exit codes.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- List projects, templates, inventories, environments
|
|
51
|
+
- Launch and monitor tasks
|
|
52
|
+
- Read task output
|
|
53
|
+
- JSON output support
|
|
54
|
+
- Bearer-token authentication (User Settings → API Tokens)
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# From PyPI
|
|
60
|
+
pip install semacli
|
|
61
|
+
|
|
62
|
+
# From source
|
|
63
|
+
pip install git+https://github.com/lduchosal/semacli.git
|
|
64
|
+
|
|
65
|
+
# Development
|
|
66
|
+
git clone https://github.com/lduchosal/semacli.git
|
|
67
|
+
cd semacli
|
|
68
|
+
pdm install
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
### Configuration
|
|
74
|
+
|
|
75
|
+
Create `semacli.ini` in the current directory or `~/.semacli.ini`:
|
|
76
|
+
|
|
77
|
+
```ini
|
|
78
|
+
[semaphore]
|
|
79
|
+
url = https://monitor.example.com/semaphore
|
|
80
|
+
project = 1
|
|
81
|
+
|
|
82
|
+
[auth]
|
|
83
|
+
method = bearer_token
|
|
84
|
+
bearer_token = your-api-token-here
|
|
85
|
+
|
|
86
|
+
[settings]
|
|
87
|
+
timeout = 30
|
|
88
|
+
verify_ssl = true
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Get a bearer token from the Semaphore UI: **User Settings → API Tokens → Create**.
|
|
92
|
+
|
|
93
|
+
### Basic Usage
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Ping the API
|
|
97
|
+
semacli ping
|
|
98
|
+
|
|
99
|
+
# List projects
|
|
100
|
+
semacli projects
|
|
101
|
+
|
|
102
|
+
# (more commands wired in as the CLI grows)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Output Options
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# JSON output
|
|
109
|
+
semacli projects --json
|
|
110
|
+
|
|
111
|
+
# Verbose debugging
|
|
112
|
+
semacli projects -v
|
|
113
|
+
semacli projects -vv
|
|
114
|
+
semacli projects -vvv
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Configuration Options
|
|
118
|
+
|
|
119
|
+
### Authentication Methods
|
|
120
|
+
|
|
121
|
+
#### Bearer token (recommended)
|
|
122
|
+
|
|
123
|
+
```ini
|
|
124
|
+
[semaphore]
|
|
125
|
+
url = https://monitor.example.com/semaphore
|
|
126
|
+
|
|
127
|
+
[auth]
|
|
128
|
+
method = bearer_token
|
|
129
|
+
bearer_token = your-api-token
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Bearer token from environment variable
|
|
133
|
+
|
|
134
|
+
```ini
|
|
135
|
+
[semaphore]
|
|
136
|
+
url = https://monitor.example.com/semaphore
|
|
137
|
+
|
|
138
|
+
[auth]
|
|
139
|
+
method = env_var
|
|
140
|
+
env_var = SEMAPHORE_TOKEN
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Exit Codes
|
|
144
|
+
|
|
145
|
+
| Code | Meaning |
|
|
146
|
+
|------|---------|
|
|
147
|
+
| 0 | Success |
|
|
148
|
+
| 1 | General error |
|
|
149
|
+
| 2 | Configuration error |
|
|
150
|
+
| 3 | Authentication error |
|
|
151
|
+
| 4 | API error |
|
|
152
|
+
| 5 | Not found |
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Clone and setup
|
|
158
|
+
git clone https://github.com/lduchosal/semacli.git
|
|
159
|
+
cd semacli
|
|
160
|
+
pdm install -G dev
|
|
161
|
+
|
|
162
|
+
# Run tests
|
|
163
|
+
pdm test
|
|
164
|
+
|
|
165
|
+
# Lint and format
|
|
166
|
+
pdm lint
|
|
167
|
+
pdm format
|
|
168
|
+
|
|
169
|
+
# Type check
|
|
170
|
+
pdm typecheck
|
|
171
|
+
|
|
172
|
+
# Build
|
|
173
|
+
pdm build
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Architecture
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
semacli/
|
|
180
|
+
├── cli/ # Click CLI interface
|
|
181
|
+
│ ├── commands/ # Individual commands
|
|
182
|
+
│ ├── decorators.py # Common CLI options
|
|
183
|
+
│ └── handlers.py # Error handlers
|
|
184
|
+
├── core/ # Core business logic
|
|
185
|
+
│ ├── client.py # Semaphore HTTP client
|
|
186
|
+
│ ├── config.py # Configuration
|
|
187
|
+
│ ├── exceptions.py # Custom exceptions
|
|
188
|
+
│ └── models.py # Data models
|
|
189
|
+
└── services/ # Business services
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
See [ARCHITECTURE.md](ARCHITECTURE.md) for the wiki classification map used by `ken wiki groom`.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
197
|
+
|
|
198
|
+
## Related Projects
|
|
199
|
+
|
|
200
|
+
- [nagioscli](https://github.com/lduchosal/nagioscli) - sibling CLI for Nagios Core (model project)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
semacli-0.1.2.dist-info/METADATA,sha256=yBgjGtyOxbo3eAnOa6VOX6YJT2HykESkbkKNiAQZUb0,6356
|
|
2
|
+
semacli-0.1.2.dist-info/WHEEL,sha256=Z36eTX6lG3PITRleSd5hAZHCcz52yg3c0JQVxKBbLW0,90
|
|
3
|
+
semacli-0.1.2.dist-info/entry_points.txt,sha256=7zsrCLOuVNd9IfaP0aF2TV3XSsqqvVs3YETheCzL2Pg,61
|
|
4
|
+
semacli-0.1.2.dist-info/licenses/LICENSE,sha256=g1T6XLdZJvABHPemjAVYU8A-72E5CGd6IFV7hjEgu8I,1066
|
|
5
|
+
semacli/__init__.py,sha256=SnmYLRA7PXwVt6N5BxS5mlZFCFFCLXv_nbKRTH5pYlU,152
|
|
6
|
+
semacli/__main__.py,sha256=CUEe_jZd8dYILSrMP2hQgb-Pv_GsqD8gCwzxH7fqpc8,105
|
|
7
|
+
semacli/cli/__init__.py,sha256=WY0qIBKIDyizPkwRYM8yPw09u4bqLBE629XAxHIFToI,327
|
|
8
|
+
semacli/cli/commands/__init__.py,sha256=bBqe2kUbWbMLwTWHtl0lD5LZzZoLvpQVhLfFMKXy0k8,340
|
|
9
|
+
semacli/cli/commands/ping.py,sha256=1JGmsaLmngFkrE9SNQ26pwg18xTMv_ZkXtbBLjDo8Nw,1113
|
|
10
|
+
semacli/cli/commands/projects.py,sha256=QtewMnAL7VVaFuMHorl3VQeVxDaIRhrKVl7sMlZyN14,1660
|
|
11
|
+
semacli/cli/decorators.py,sha256=vZIgVAupPX0o3mMv-GUd_ar1MmsPw17MTuf37RJY28I,755
|
|
12
|
+
semacli/cli/handlers.py,sha256=CMCmrqEiA7Aaqmzi4cqSKQtwgkwfixB6ySmbCFdMNrE,1528
|
|
13
|
+
semacli/core/__init__.py,sha256=NJ9XWDYoKERi63-CT9QlJs0_PoWuoCrMcOa193AdSJc,31
|
|
14
|
+
semacli/core/client.py,sha256=1i7AxRorwKDYzXya9Qnjb4d_n4kpb-kUpvr6ACbsxaU,4677
|
|
15
|
+
semacli/core/config.py,sha256=yr3TXTj_57I46lj9-hw9JIm1iRewaIaLvLsK_cftjEs,3086
|
|
16
|
+
semacli/core/exceptions.py,sha256=Z2-5YhHvGpfv91MIkzDQYNik-vmxDw4xumW2V6B3sXk,539
|
|
17
|
+
semacli/core/models.py,sha256=JC1NKMehj8dw4t33lcyiYzigP4kU6TgIme9Xnbd8GV0,1170
|
|
18
|
+
semacli/services/__init__.py,sha256=HQcKZzyxE9G8JslzmKAT9HvYlDDMXfEPIEDoXVRLEwk,35
|
|
19
|
+
semacli-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lduchosal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|