semacli 0.1.2__tar.gz

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-0.1.2/LICENSE ADDED
@@ -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.
semacli-0.1.2/PKG-INFO ADDED
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/semacli.svg)](https://pypi.org/project/semacli/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/semacli.svg)](https://pypi.org/project/semacli/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
+ [![Build](https://github.com/lduchosal/semacli/actions/workflows/python-package.yml/badge.svg)](https://github.com/lduchosal/semacli/actions/workflows/python-package.yml)
32
+ [![Publish](https://github.com/lduchosal/semacli/actions/workflows/python-publish.yml/badge.svg)](https://github.com/lduchosal/semacli/actions/workflows/python-publish.yml)
33
+ [![codecov](https://codecov.io/gh/lduchosal/semacli/branch/main/graph/badge.svg)](https://codecov.io/gh/lduchosal/semacli)
34
+ [![Docstring coverage](./interrogate_badge.svg)](./interrogate_badge.svg)
35
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
36
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
37
+ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
38
+ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
39
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=bugs)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
40
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
41
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
42
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=sqale_index)](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,175 @@
1
+ # semacli
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/semacli.svg)](https://pypi.org/project/semacli/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/semacli.svg)](https://pypi.org/project/semacli/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Build](https://github.com/lduchosal/semacli/actions/workflows/python-package.yml/badge.svg)](https://github.com/lduchosal/semacli/actions/workflows/python-package.yml)
7
+ [![Publish](https://github.com/lduchosal/semacli/actions/workflows/python-publish.yml/badge.svg)](https://github.com/lduchosal/semacli/actions/workflows/python-publish.yml)
8
+ [![codecov](https://codecov.io/gh/lduchosal/semacli/branch/main/graph/badge.svg)](https://codecov.io/gh/lduchosal/semacli)
9
+ [![Docstring coverage](./interrogate_badge.svg)](./interrogate_badge.svg)
10
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
11
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
12
+ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
13
+ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
14
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=bugs)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
15
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
16
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
17
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=lduchosal_semacli&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=lduchosal_semacli)
18
+
19
+ A CLI tool to manage [Semaphore UI](https://semaphoreui.com) (ansible-semaphore) via its HTTP REST API.
20
+
21
+ Designed for LLM/agent and automation use — deterministic commands, JSON output, exit codes.
22
+
23
+ ## Features
24
+
25
+ - List projects, templates, inventories, environments
26
+ - Launch and monitor tasks
27
+ - Read task output
28
+ - JSON output support
29
+ - Bearer-token authentication (User Settings → API Tokens)
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ # From PyPI
35
+ pip install semacli
36
+
37
+ # From source
38
+ pip install git+https://github.com/lduchosal/semacli.git
39
+
40
+ # Development
41
+ git clone https://github.com/lduchosal/semacli.git
42
+ cd semacli
43
+ pdm install
44
+ ```
45
+
46
+ ## Quick Start
47
+
48
+ ### Configuration
49
+
50
+ Create `semacli.ini` in the current directory or `~/.semacli.ini`:
51
+
52
+ ```ini
53
+ [semaphore]
54
+ url = https://monitor.example.com/semaphore
55
+ project = 1
56
+
57
+ [auth]
58
+ method = bearer_token
59
+ bearer_token = your-api-token-here
60
+
61
+ [settings]
62
+ timeout = 30
63
+ verify_ssl = true
64
+ ```
65
+
66
+ Get a bearer token from the Semaphore UI: **User Settings → API Tokens → Create**.
67
+
68
+ ### Basic Usage
69
+
70
+ ```bash
71
+ # Ping the API
72
+ semacli ping
73
+
74
+ # List projects
75
+ semacli projects
76
+
77
+ # (more commands wired in as the CLI grows)
78
+ ```
79
+
80
+ ## Output Options
81
+
82
+ ```bash
83
+ # JSON output
84
+ semacli projects --json
85
+
86
+ # Verbose debugging
87
+ semacli projects -v
88
+ semacli projects -vv
89
+ semacli projects -vvv
90
+ ```
91
+
92
+ ## Configuration Options
93
+
94
+ ### Authentication Methods
95
+
96
+ #### Bearer token (recommended)
97
+
98
+ ```ini
99
+ [semaphore]
100
+ url = https://monitor.example.com/semaphore
101
+
102
+ [auth]
103
+ method = bearer_token
104
+ bearer_token = your-api-token
105
+ ```
106
+
107
+ #### Bearer token from environment variable
108
+
109
+ ```ini
110
+ [semaphore]
111
+ url = https://monitor.example.com/semaphore
112
+
113
+ [auth]
114
+ method = env_var
115
+ env_var = SEMAPHORE_TOKEN
116
+ ```
117
+
118
+ ## Exit Codes
119
+
120
+ | Code | Meaning |
121
+ |------|---------|
122
+ | 0 | Success |
123
+ | 1 | General error |
124
+ | 2 | Configuration error |
125
+ | 3 | Authentication error |
126
+ | 4 | API error |
127
+ | 5 | Not found |
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ # Clone and setup
133
+ git clone https://github.com/lduchosal/semacli.git
134
+ cd semacli
135
+ pdm install -G dev
136
+
137
+ # Run tests
138
+ pdm test
139
+
140
+ # Lint and format
141
+ pdm lint
142
+ pdm format
143
+
144
+ # Type check
145
+ pdm typecheck
146
+
147
+ # Build
148
+ pdm build
149
+ ```
150
+
151
+ ## Architecture
152
+
153
+ ```
154
+ semacli/
155
+ ├── cli/ # Click CLI interface
156
+ │ ├── commands/ # Individual commands
157
+ │ ├── decorators.py # Common CLI options
158
+ │ └── handlers.py # Error handlers
159
+ ├── core/ # Core business logic
160
+ │ ├── client.py # Semaphore HTTP client
161
+ │ ├── config.py # Configuration
162
+ │ ├── exceptions.py # Custom exceptions
163
+ │ └── models.py # Data models
164
+ └── services/ # Business services
165
+ ```
166
+
167
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for the wiki classification map used by `ken wiki groom`.
168
+
169
+ ## License
170
+
171
+ MIT License - see [LICENSE](LICENSE) for details.
172
+
173
+ ## Related Projects
174
+
175
+ - [nagioscli](https://github.com/lduchosal/nagioscli) - sibling CLI for Nagios Core (model project)
@@ -0,0 +1,178 @@
1
+ [build-system]
2
+ requires = [
3
+ "pdm-backend",
4
+ ]
5
+ build-backend = "pdm.backend"
6
+
7
+ [project]
8
+ name = "semacli"
9
+ dynamic = []
10
+ authors = [
11
+ { name = "lduchosal", email = "lduchosal@github.com" },
12
+ ]
13
+ description = "A CLI tool to manage Semaphore UI (ansible-semaphore) via HTTP REST API"
14
+ readme = "README.md"
15
+ requires-python = ">=3.10"
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: System :: Systems Administration",
27
+ ]
28
+ keywords = [
29
+ "semaphore",
30
+ "ansible",
31
+ "cli",
32
+ "api",
33
+ "devops",
34
+ ]
35
+ dependencies = [
36
+ "click>=8.0,<9.0",
37
+ ]
38
+ version = "0.1.2"
39
+
40
+ [project.license]
41
+ text = "MIT"
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/lduchosal/semacli"
45
+ "Bug Reports" = "https://github.com/lduchosal/semacli/issues"
46
+ Source = "https://github.com/lduchosal/semacli"
47
+ Documentation = "https://github.com/lduchosal/semacli/blob/main/README.md"
48
+
49
+ [project.scripts]
50
+ semacli = "semacli.cli:main"
51
+
52
+ [tool.pdm]
53
+ distribution = true
54
+
55
+ [tool.pdm.dev-dependencies]
56
+ dev = [
57
+ "pytest>=6.0",
58
+ "pytest-cov>=2.0",
59
+ "black>=21.0",
60
+ "mypy>=0.800",
61
+ "ruff>=0.1.0",
62
+ "pdm>=2.0.0",
63
+ "pdm-bump",
64
+ "interrogate>=1.7.0",
65
+ ]
66
+
67
+ [tool.pdm.version]
68
+ source = "file"
69
+ path = "semacli/__init__.py"
70
+
71
+ [tool.pdm.scripts]
72
+ format = "ruff format"
73
+ typecheck = "mypy semacli/"
74
+ build = "python -m build"
75
+ install = "pdm install"
76
+ install-dev = "pdm install -G dev"
77
+ clihelp = "semacli --help"
78
+ test = "env PYTHONPATH=src python -m pytest tests/ -v --cov=semacli --cov-report=term-missing"
79
+ test-quick = "env PYTHONPATH=src python -m pytest tests/ --tb=no -q"
80
+ test-unit = "env PYTHONPATH=src python -m pytest tests/unit/ -v"
81
+ test-integration = "env PYTHONPATH=src python -m pytest tests/integration/ -v"
82
+ test-cov = "env PYTHONPATH=src python -m pytest tests/ -v --cov=semacli --cov-report=html --cov-report=term-missing"
83
+ lint = "ruff check semacli/ tests/"
84
+ lint-fix = "ruff check --fix semacli/ tests/"
85
+ format-check = "black --check semacli/ tests/"
86
+ interrogate = "interrogate semacli/"
87
+ test-cov-xml = "env PYTHONPATH=src python -m pytest tests/ --cov=semacli --cov-report=xml --cov-report=term --cov-fail-under=80"
88
+ check = [
89
+ "lint",
90
+ "format-check",
91
+ "typecheck",
92
+ "test",
93
+ ]
94
+ clean = "rm -rf .coverage htmlcov/ .pytest_cache/ __pycache__/ semacli/__pycache__/"
95
+ version-patch = "pdm bump patch"
96
+ version-minor = "pdm bump minor"
97
+ version-major = "pdm bump major"
98
+
99
+ [tool.pdm.scripts.cli]
100
+ composite = [
101
+ "clihelp",
102
+ ]
103
+
104
+ [tool.pdm.scripts.all]
105
+ composite = [
106
+ "install",
107
+ "format",
108
+ "build",
109
+ "test",
110
+ "cli",
111
+ ]
112
+
113
+ [tool.black]
114
+ line-length = 100
115
+ target-version = [
116
+ "py310",
117
+ ]
118
+
119
+ [tool.mypy]
120
+ python_version = "3.10"
121
+ warn_return_any = true
122
+ warn_unused_configs = true
123
+ disallow_untyped_defs = true
124
+ disallow_incomplete_defs = true
125
+ check_untyped_defs = true
126
+ disallow_untyped_decorators = false
127
+ no_implicit_optional = true
128
+ warn_redundant_casts = true
129
+ warn_unused_ignores = true
130
+ warn_no_return = true
131
+ warn_unreachable = true
132
+ strict_equality = true
133
+ ignore_missing_imports = true
134
+
135
+ [tool.pytest.ini_options]
136
+ testpaths = [
137
+ "tests",
138
+ ]
139
+ python_files = "test_*.py"
140
+ python_classes = "Test*"
141
+ python_functions = "test_*"
142
+ addopts = "-v"
143
+
144
+ [tool.pdm-bump]
145
+ version-files = [
146
+ "semacli/__init__.py:__version__",
147
+ ]
148
+
149
+ [tool.ruff]
150
+ line-length = 100
151
+ target-version = "py310"
152
+
153
+ [tool.ruff.lint]
154
+ select = [
155
+ "E",
156
+ "F",
157
+ "W",
158
+ "I",
159
+ "UP",
160
+ "B",
161
+ "C4",
162
+ ]
163
+ ignore = [
164
+ "E501",
165
+ ]
166
+
167
+ [tool.interrogate]
168
+ fail-under = 0
169
+ ignore-init-method = true
170
+ ignore-init-module = true
171
+ exclude = [
172
+ "tests",
173
+ "docs",
174
+ "build",
175
+ "dist",
176
+ ]
177
+ generate-badge = "."
178
+ badge-format = "svg"
@@ -0,0 +1,5 @@
1
+ """semacli - A CLI tool to manage Semaphore UI via HTTP REST API."""
2
+
3
+ __version__ = "0.1.2"
4
+ __author__ = "lduchosal"
5
+ __email__ = "lduchosal@github.com"
@@ -0,0 +1,6 @@
1
+ """Main entry point for semacli."""
2
+
3
+ from semacli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
@@ -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)
@@ -0,0 +1 @@
1
+ """Core module for semacli."""
@@ -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
+ )
@@ -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
@@ -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."""
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,84 @@
1
+ """Tests for semacli.core.config."""
2
+
3
+ import textwrap
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from semacli.core.config import SemaphoreConfig, load_config
9
+ from semacli.core.exceptions import ConfigurationError
10
+
11
+
12
+ def _write_ini(tmp_path: Path, body: str) -> Path:
13
+ path = tmp_path / "semacli.ini"
14
+ path.write_text(textwrap.dedent(body).lstrip())
15
+ return path
16
+
17
+
18
+ class TestLoadConfig:
19
+ def test_missing_file_raises(self, tmp_path: Path) -> None:
20
+ with pytest.raises(ConfigurationError):
21
+ load_config(str(tmp_path / "nope.ini"))
22
+
23
+ def test_bearer_token_method(self, tmp_path: Path) -> None:
24
+ ini = _write_ini(
25
+ tmp_path,
26
+ """
27
+ [semaphore]
28
+ url = https://semaphore.example/
29
+
30
+ [auth]
31
+ method = bearer_token
32
+ bearer_token = abc123
33
+
34
+ [settings]
35
+ timeout = 42
36
+ verify_ssl = false
37
+ """,
38
+ )
39
+ cfg = load_config(str(ini))
40
+ assert isinstance(cfg, SemaphoreConfig)
41
+ assert cfg.url == "https://semaphore.example"
42
+ assert cfg.bearer_token == "abc123"
43
+ assert cfg.timeout == 42
44
+ assert cfg.verify_ssl is False
45
+
46
+ def test_env_var_method(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
47
+ monkeypatch.setenv("SEMA_TOKEN_X", "from-env")
48
+ ini = _write_ini(
49
+ tmp_path,
50
+ """
51
+ [semaphore]
52
+ url = https://semaphore.example
53
+
54
+ [auth]
55
+ method = env_var
56
+ env_var = SEMA_TOKEN_X
57
+ """,
58
+ )
59
+ cfg = load_config(str(ini))
60
+ assert cfg.bearer_token == "from-env"
61
+
62
+ def test_missing_url_raises(self, tmp_path: Path) -> None:
63
+ ini = _write_ini(
64
+ tmp_path,
65
+ """
66
+ [semaphore]
67
+ project = 1
68
+ """,
69
+ )
70
+ with pytest.raises(ConfigurationError):
71
+ load_config(str(ini))
72
+
73
+ def test_project_parsed_as_int(self, tmp_path: Path) -> None:
74
+ ini = _write_ini(
75
+ tmp_path,
76
+ """
77
+ [semaphore]
78
+ url = https://semaphore.example
79
+ project = 7
80
+ bearer_token = t
81
+ """,
82
+ )
83
+ cfg = load_config(str(ini))
84
+ assert cfg.project == 7