Compare commits
10 Commits
a05c8751cd
...
ba4bd785aa
| Author | SHA1 | Date | |
|---|---|---|---|
| ba4bd785aa | |||
| d88cedc8a0 | |||
| f1a75cab8c | |||
| ddc175edc3 | |||
| ec5dda61ef | |||
| 1cb699820f | |||
| 03ba3dc88e | |||
| c1258bda3e | |||
| dfe91e2ce1 | |||
| 3664e98801 |
21
CLAUDE.md
21
CLAUDE.md
@@ -33,4 +33,23 @@ uv run pytest
|
||||
|
||||
## Configuration
|
||||
|
||||
Credentials can be passed via command-line options (`-u/--url`, `-U/--username`, `-P/--password`) or environment variables (`JENKINS_URL`, `JENKINS_USERNAME`, `JENKINS_PASSWORD` or `JENKINS_TOKEN`).
|
||||
Credentials are read from `~/.config/zzpyjenkins/config.toml`. Create this file with your Jenkins server configurations:
|
||||
|
||||
```toml
|
||||
[work]
|
||||
url = "https://jenkins.work.com"
|
||||
username = "myuser"
|
||||
password = "mypassword"
|
||||
|
||||
[home]
|
||||
url = "http://localhost:8080"
|
||||
username = "admin"
|
||||
password = "admin123"
|
||||
```
|
||||
|
||||
Use `-s/--server` option to select which server to use:
|
||||
|
||||
```bash
|
||||
zzpyjenkins -s work info
|
||||
zzpyjenkins --server home list
|
||||
```
|
||||
|
||||
44
README.md
44
README.md
@@ -25,48 +25,54 @@ uv run zzpyjenkins --help
|
||||
|
||||
## Configuration
|
||||
|
||||
You can provide Jenkins credentials via command-line options or environment variables:
|
||||
Create a config file at `~/.config/zzpyjenkins/config.toml` with your Jenkins server configurations:
|
||||
|
||||
### Command-line options (preferred)
|
||||
```toml
|
||||
[work]
|
||||
url = "https://jenkins.work.com"
|
||||
username = "myuser"
|
||||
password = "my-api-token"
|
||||
|
||||
```bash
|
||||
zzpyjenkins -u https://jenkins.example.com -U username -P token info
|
||||
[home]
|
||||
url = "http://localhost:8080"
|
||||
username = "admin"
|
||||
password = "admin123"
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
export JENKINS_URL="https://your-jenkins-server.com"
|
||||
export JENKINS_USERNAME="your_username"
|
||||
export JENKINS_PASSWORD="your_api_token" # or JENKINS_TOKEN
|
||||
```
|
||||
Use the `-s/--server` option to select which server to use.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Show Jenkins server info
|
||||
zzpyjenkins info
|
||||
zzpyjenkins -s work info
|
||||
|
||||
# List all jobs
|
||||
zzpyjenkins list
|
||||
zzpyjenkins -s work list
|
||||
|
||||
# List jobs with filter
|
||||
zzpyjenkins list -p "my-project"
|
||||
zzpyjenkins -s work list -p "my-project"
|
||||
|
||||
# Trigger a build
|
||||
zzpyjenkins build my-job-name
|
||||
zzpyjenkins -s work build my-job-name
|
||||
|
||||
# Trigger a build with parameters
|
||||
zzpyjenkins build my-job-name -p BRANCH=main -p ENV=staging
|
||||
zzpyjenkins -s work build my-job-name -p BRANCH=main -p ENV=staging
|
||||
|
||||
# Get build status
|
||||
zzpyjenkins status my-job-name
|
||||
zzpyjenkins -s work status my-job-name
|
||||
|
||||
# Get specific build status
|
||||
zzpyjenkins status my-job-name 123
|
||||
zzpyjenkins -s work status my-job-name 123
|
||||
|
||||
# Watch build progress
|
||||
zzpyjenkins watch my-job-name
|
||||
zzpyjenkins -s work watch my-job-name
|
||||
|
||||
# List running jobs
|
||||
zzpyjenkins -s work running
|
||||
|
||||
# Show only count of running jobs
|
||||
zzpyjenkins -s work running -c
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
531
docs/superpowers/plans/2026-03-18-config-file-support.md
Normal file
531
docs/superpowers/plans/2026-03-18-config-file-support.md
Normal file
@@ -0,0 +1,531 @@
|
||||
# Config File Support Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace CLI credential options with TOML config file support.
|
||||
|
||||
**Architecture:** Read Jenkins server configurations from `~/.config/zzpyjenkins/config.toml`. CLI uses `-s/--server` option to select which configured server to use.
|
||||
|
||||
**Tech Stack:** Python 3.11+ (tomllib built-in), Click, Rich
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/zzpyjenkins/config.py` | Create | Load and parse TOML config file |
|
||||
| `src/zzpyjenkins/cli.py` | Modify | Replace credential options with `--server` option |
|
||||
| `tests/test_config.py` | Create | Test config loading and validation |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Config Module
|
||||
|
||||
### Task 1: Config Loading
|
||||
|
||||
**Files:**
|
||||
- Create: `src/zzpyjenkins/config.py`
|
||||
- Create: `tests/test_config.py`
|
||||
|
||||
- [ ] **Step 0: Create tests directory**
|
||||
|
||||
```bash
|
||||
mkdir -p tests
|
||||
touch tests/__init__.py
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing test for load_config with missing file**
|
||||
|
||||
```python
|
||||
# tests/test_config.py
|
||||
"""Tests for config module."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from zzpyjenkins.config import load_config, ConfigError
|
||||
|
||||
|
||||
def test_load_config_file_not_found():
|
||||
"""Test load_config raises error when config file doesn't exist."""
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(Path("/nonexistent/path/config.toml"))
|
||||
assert "Config file not found" in str(exc_info.value)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py -v`
|
||||
Expected: FAIL with "ModuleNotFoundError" or "ImportError"
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation - ConfigError and load_config skeleton**
|
||||
|
||||
```python
|
||||
# src/zzpyjenkins/config.py
|
||||
"""Configuration file handling."""
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when configuration is invalid or missing."""
|
||||
pass
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> dict:
|
||||
"""Load and parse TOML config file.
|
||||
|
||||
Args:
|
||||
config_path: Path to config.toml file
|
||||
|
||||
Returns:
|
||||
Dictionary of server configurations
|
||||
|
||||
Raises:
|
||||
ConfigError: If file doesn't exist or is invalid
|
||||
"""
|
||||
if not config_path.exists():
|
||||
raise ConfigError(f"Config file not found: {config_path}")
|
||||
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise ConfigError(f"Invalid TOML syntax: {e}") from e
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4.5: Add test for invalid TOML syntax**
|
||||
|
||||
```python
|
||||
# Add to tests/test_config.py
|
||||
import tempfile
|
||||
|
||||
|
||||
def test_load_config_invalid_toml():
|
||||
"""Test load_config raises error for invalid TOML syntax."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write("invalid toml [[[[")
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(temp_path)
|
||||
assert "Invalid TOML syntax" in str(exc_info.value)
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/zzpyjenkins/config.py tests/test_config.py
|
||||
git commit -m "feat: add config module with load_config function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Get Server Config
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/zzpyjenkins/config.py`
|
||||
- Modify: `tests/test_config.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for get_server_config**
|
||||
|
||||
```python
|
||||
# Add to tests/test_config.py
|
||||
# Update import to include get_server_config:
|
||||
from zzpyjenkins.config import load_config, ConfigError, get_server_config
|
||||
|
||||
|
||||
def test_get_server_config_success():
|
||||
"""Test getting a valid server config."""
|
||||
config = {
|
||||
"work": {
|
||||
"url": "https://jenkins.example.com",
|
||||
"username": "user",
|
||||
"password": "pass"
|
||||
}
|
||||
}
|
||||
result = get_server_config(config, "work")
|
||||
assert result == ("https://jenkins.example.com", "user", "pass")
|
||||
|
||||
|
||||
def test_get_server_config_not_found():
|
||||
"""Test error when server name doesn't exist."""
|
||||
config = {
|
||||
"work": {
|
||||
"url": "https://jenkins.example.com",
|
||||
"username": "user",
|
||||
"password": "pass"
|
||||
}
|
||||
}
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
get_server_config(config, "home")
|
||||
assert "Server 'home' not found" in str(exc_info.value)
|
||||
assert "work" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_server_config_missing_field():
|
||||
"""Test error when required field is missing."""
|
||||
config = {
|
||||
"incomplete": {
|
||||
"url": "https://jenkins.example.com",
|
||||
"username": "user"
|
||||
# missing password
|
||||
}
|
||||
}
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
get_server_config(config, "incomplete")
|
||||
assert "password" in str(exc_info.value)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py -v`
|
||||
Expected: FAIL with "NameError: name 'get_server_config' is not defined"
|
||||
|
||||
- [ ] **Step 3: Write implementation for get_server_config**
|
||||
|
||||
```python
|
||||
# Add to src/zzpyjenkins/config.py
|
||||
|
||||
REQUIRED_FIELDS = ["url", "username", "password"]
|
||||
|
||||
|
||||
def get_server_config(config: dict, server_name: str) -> tuple[str, str, str]:
|
||||
"""Extract server configuration from loaded config.
|
||||
|
||||
Args:
|
||||
config: Loaded config dictionary
|
||||
server_name: Name of server section to use
|
||||
|
||||
Returns:
|
||||
Tuple of (url, username, password)
|
||||
|
||||
Raises:
|
||||
ConfigError: If server not found or missing required fields
|
||||
"""
|
||||
if server_name not in config:
|
||||
available = ", ".join(config.keys())
|
||||
raise ConfigError(
|
||||
f"Server '{server_name}' not found. Available servers: {available}"
|
||||
)
|
||||
|
||||
server_config = config[server_name]
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field not in server_config:
|
||||
raise ConfigError(
|
||||
f"Missing required field '{field}' in server '{server_name}'"
|
||||
)
|
||||
|
||||
return (
|
||||
server_config["url"],
|
||||
server_config["username"],
|
||||
server_config["password"],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/zzpyjenkins/config.py tests/test_config.py
|
||||
git commit -m "feat: add get_server_config function with validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Default Config Path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/zzpyjenkins/config.py`
|
||||
- Modify: `tests/test_config.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for get_default_config_path**
|
||||
|
||||
```python
|
||||
# Add to tests/test_config.py
|
||||
# Update import to include get_default_config_path:
|
||||
from zzpyjenkins.config import (
|
||||
load_config,
|
||||
ConfigError,
|
||||
get_server_config,
|
||||
get_default_config_path,
|
||||
)
|
||||
|
||||
|
||||
def test_get_default_config_path():
|
||||
"""Test default config path is correct."""
|
||||
path = get_default_config_path()
|
||||
assert path.name == "config.toml"
|
||||
assert ".config" in str(path)
|
||||
assert "zzpyjenkins" in str(path)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py::test_get_default_config_path -v`
|
||||
Expected: FAIL with "NameError: name 'get_default_config_path' is not defined"
|
||||
|
||||
- [ ] **Step 3: Write implementation for get_default_config_path**
|
||||
|
||||
```python
|
||||
# Add to src/zzpyjenkins/config.py
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def get_default_config_path() -> Path:
|
||||
"""Get the default config file path.
|
||||
|
||||
Returns:
|
||||
Path to ~/.config/zzpyjenkins/config.toml
|
||||
"""
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg_config:
|
||||
config_dir = Path(xdg_config)
|
||||
else:
|
||||
config_dir = Path.home() / ".config"
|
||||
|
||||
return config_dir / "zzpyjenkins" / "config.toml"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py::test_get_default_config_path -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/zzpyjenkins/config.py tests/test_config.py
|
||||
git commit -m "feat: add get_default_config_path function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: CLI Integration
|
||||
|
||||
### Task 4: Update CLI Options
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/zzpyjenkins/cli.py`
|
||||
|
||||
- [ ] **Step 1: Update imports and add server option**
|
||||
|
||||
Replace the current imports and main function with:
|
||||
|
||||
```python
|
||||
# src/zzpyjenkins/cli.py
|
||||
"""Command-line interface for zzpyjenkins."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from .config import ConfigError, get_default_config_path, get_server_config, load_config
|
||||
from .jenkins_client import JenkinsClient
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--server", "-s", required=True, help="Server name from config file")
|
||||
@click.version_option()
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, server: str):
|
||||
"""A CLI tool to interact with Jenkins server for building jobs."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["server"] = server
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update get_client function**
|
||||
|
||||
Replace the current get_client function with:
|
||||
|
||||
```python
|
||||
# src/zzpyjenkins/cli.py (replace get_client function)
|
||||
|
||||
def get_client(ctx: click.Context) -> JenkinsClient:
|
||||
"""Create Jenkins client from config file."""
|
||||
server_name = ctx.obj.get("server")
|
||||
|
||||
if not server_name:
|
||||
console.print("[red]Error: Server name is required. Use -s/--server option.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
config_path = get_default_config_path()
|
||||
|
||||
try:
|
||||
config = load_config(config_path)
|
||||
url, username, password = get_server_config(config, server_name)
|
||||
except ConfigError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
return JenkinsClient(url, username, password)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove old environment variable references**
|
||||
|
||||
Remove the `import os` line (no longer needed) and any remaining references to environment variables.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify existing functionality still works**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/zzpyjenkins/cli.py
|
||||
git commit -m "feat: replace credential options with --server config option"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Integration Test and Finalization
|
||||
|
||||
### Task 5: Integration Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_config.py`
|
||||
|
||||
- [ ] **Step 1: Write integration test with temp config file**
|
||||
|
||||
```python
|
||||
# Add to tests/test_config.py
|
||||
# tempfile is already imported in Task 1
|
||||
|
||||
def test_full_config_flow():
|
||||
"""Test complete config loading flow with temp file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write("""
|
||||
[work]
|
||||
url = "https://jenkins.example.com"
|
||||
username = "testuser"
|
||||
password = "testpass"
|
||||
""")
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
config = load_config(temp_path)
|
||||
url, username, password = get_server_config(config, "work")
|
||||
|
||||
assert url == "https://jenkins.example.com"
|
||||
assert username == "testuser"
|
||||
assert password == "testpass"
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_config.py
|
||||
git commit -m "test: add integration test for config flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Update Configuration section**
|
||||
|
||||
Replace the Configuration section with:
|
||||
|
||||
```markdown
|
||||
## Configuration
|
||||
|
||||
Credentials are read from `~/.config/zzpyjenkins/config.toml`. Create this file with your Jenkins server configurations:
|
||||
|
||||
```toml
|
||||
[work]
|
||||
url = "https://jenkins.work.com"
|
||||
username = "myuser"
|
||||
password = "mypassword"
|
||||
|
||||
[home]
|
||||
url = "http://localhost:8080"
|
||||
username = "admin"
|
||||
password = "admin123"
|
||||
```
|
||||
|
||||
Use `-s/--server` option to select which server to use:
|
||||
|
||||
```bash
|
||||
zzpyjenkins -s work info
|
||||
zzpyjenkins --server home list
|
||||
```
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md with config file usage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run Lint and Format
|
||||
|
||||
- [ ] **Step 1: Format code**
|
||||
|
||||
Run: `uv run ruff format .`
|
||||
|
||||
- [ ] **Step 2: Lint code**
|
||||
|
||||
Run: `uv run ruff check . --fix`
|
||||
|
||||
- [ ] **Step 3: Run all tests**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 4: Commit any formatting changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "style: format and lint code"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 1 | Create config module with `load_config` |
|
||||
| 2 | Add `get_server_config` with validation |
|
||||
| 3 | Add `get_default_config_path` |
|
||||
| 4 | Update CLI to use `--server` option |
|
||||
| 5 | Integration test |
|
||||
| 6 | Update documentation |
|
||||
| 7 | Lint and format |
|
||||
@@ -1,55 +1,34 @@
|
||||
"""Command-line interface for zzpyjenkins."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from .config import ConfigError, get_default_config_path, get_server_config, load_config
|
||||
from .jenkins_client import JenkinsClient
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--url", "-u", help="Jenkins server URL (default: JENKINS_URL env)")
|
||||
@click.option(
|
||||
"--username", "-U", help="Jenkins username (default: JENKINS_USERNAME env)"
|
||||
)
|
||||
@click.option(
|
||||
"--password", "-P", help="Jenkins password/token (default: JENKINS_PASSWORD env)"
|
||||
)
|
||||
@click.option("--server", "-s", required=True, help="Server name from config file")
|
||||
@click.version_option()
|
||||
@click.pass_context
|
||||
def main(
|
||||
ctx: click.Context,
|
||||
url: Optional[str],
|
||||
username: Optional[str],
|
||||
password: Optional[str],
|
||||
):
|
||||
def main(ctx: click.Context, server: str):
|
||||
"""A CLI tool to interact with Jenkins server for building jobs."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["url"] = url
|
||||
ctx.obj["username"] = username
|
||||
ctx.obj["password"] = password
|
||||
ctx.obj["server"] = server
|
||||
|
||||
|
||||
def get_client(ctx: click.Context) -> JenkinsClient:
|
||||
"""Create Jenkins client from options or environment variables."""
|
||||
url = ctx.obj.get("url") or os.environ.get("JENKINS_URL")
|
||||
username = ctx.obj.get("username") or os.environ.get("JENKINS_USERNAME")
|
||||
password = (
|
||||
ctx.obj.get("password")
|
||||
or os.environ.get("JENKINS_PASSWORD")
|
||||
or os.environ.get("JENKINS_TOKEN")
|
||||
)
|
||||
"""Create Jenkins client from config file."""
|
||||
server_name = ctx.obj["server"]
|
||||
config_path = get_default_config_path()
|
||||
|
||||
if not all([url, username, password]):
|
||||
console.print("[red]Error: Missing Jenkins configuration.[/red]")
|
||||
console.print(
|
||||
"Use --url/--username/--password options or set environment variables:"
|
||||
)
|
||||
console.print(" JENKINS_URL, JENKINS_USERNAME, JENKINS_PASSWORD")
|
||||
try:
|
||||
config = load_config(config_path)
|
||||
url, username, password = get_server_config(config, server_name)
|
||||
except ConfigError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
return JenkinsClient(url, username, password)
|
||||
@@ -66,7 +45,7 @@ def info(ctx: click.Context):
|
||||
@main.command("list")
|
||||
@click.option("--pattern", "-p", default=None, help="Filter jobs by name pattern")
|
||||
@click.pass_context
|
||||
def list_jobs(ctx: click.Context, pattern: Optional[str]):
|
||||
def list_jobs(ctx: click.Context, pattern: str | None):
|
||||
"""List all jobs on Jenkins server."""
|
||||
client = get_client(ctx)
|
||||
client.list_jobs(pattern)
|
||||
@@ -96,7 +75,7 @@ def build(ctx: click.Context, job_name: str, params: tuple[str, ...]):
|
||||
@click.argument("job_name")
|
||||
@click.argument("build_number", type=int, required=False)
|
||||
@click.pass_context
|
||||
def status(ctx: click.Context, job_name: str, build_number: Optional[int]):
|
||||
def status(ctx: click.Context, job_name: str, build_number: int | None):
|
||||
"""Get build status for a job."""
|
||||
client = get_client(ctx)
|
||||
client.get_build_status(job_name, build_number)
|
||||
|
||||
75
src/zzpyjenkins/config.py
Normal file
75
src/zzpyjenkins/config.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Configuration file handling."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when configuration is invalid or missing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_default_config_path() -> Path:
|
||||
"""Get the default config file path.
|
||||
|
||||
Returns:
|
||||
Path to ~/.config/zzpyjenkins/config.toml
|
||||
"""
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg_config:
|
||||
config_dir = Path(xdg_config)
|
||||
else:
|
||||
config_dir = Path.home() / ".config"
|
||||
|
||||
return config_dir / "zzpyjenkins" / "config.toml"
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> dict:
|
||||
"""Load and parse TOML config file."""
|
||||
if not config_path.exists():
|
||||
raise ConfigError(f"Config file not found: {config_path}")
|
||||
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise ConfigError(f"Invalid TOML syntax: {e}") from e
|
||||
|
||||
|
||||
REQUIRED_FIELDS = ["url", "username", "password"]
|
||||
|
||||
|
||||
def get_server_config(config: dict, server_name: str) -> tuple[str, str, str]:
|
||||
"""Extract server configuration from loaded config.
|
||||
|
||||
Args:
|
||||
config: Loaded config dictionary
|
||||
server_name: Name of server section to use
|
||||
|
||||
Returns:
|
||||
Tuple of (url, username, password)
|
||||
|
||||
Raises:
|
||||
ConfigError: If server not found or missing required fields
|
||||
"""
|
||||
if server_name not in config:
|
||||
available = ", ".join(config.keys())
|
||||
raise ConfigError(
|
||||
f"Server '{server_name}' not found. Available servers: {available}"
|
||||
)
|
||||
|
||||
server_config = config[server_name]
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field not in server_config:
|
||||
raise ConfigError(
|
||||
f"Missing required field '{field}' in server '{server_name}'"
|
||||
)
|
||||
|
||||
return (
|
||||
server_config["url"],
|
||||
server_config["username"],
|
||||
server_config["password"],
|
||||
)
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
100
tests/test_config.py
Normal file
100
tests/test_config.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Tests for config module."""
|
||||
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from zzpyjenkins.config import (
|
||||
load_config,
|
||||
ConfigError,
|
||||
get_server_config,
|
||||
get_default_config_path,
|
||||
)
|
||||
|
||||
|
||||
def test_load_config_file_not_found():
|
||||
"""Test load_config raises error when config file doesn't exist."""
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(Path("/nonexistent/path/config.toml"))
|
||||
assert "Config file not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_load_config_invalid_toml():
|
||||
"""Test load_config raises error for invalid TOML syntax."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write("invalid toml [[[[")
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(temp_path)
|
||||
assert "Invalid TOML syntax" in str(exc_info.value)
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def test_get_server_config_success():
|
||||
"""Test getting a valid server config."""
|
||||
config = {
|
||||
"work": {
|
||||
"url": "https://jenkins.example.com",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
}
|
||||
}
|
||||
result = get_server_config(config, "work")
|
||||
assert result == ("https://jenkins.example.com", "user", "pass")
|
||||
|
||||
|
||||
def test_get_server_config_not_found():
|
||||
"""Test error when server name doesn't exist."""
|
||||
config = {
|
||||
"work": {
|
||||
"url": "https://jenkins.example.com",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
}
|
||||
}
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
get_server_config(config, "home")
|
||||
assert "Server 'home' not found" in str(exc_info.value)
|
||||
assert "work" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_server_config_missing_field():
|
||||
"""Test error when required field is missing."""
|
||||
config = {"incomplete": {"url": "https://jenkins.example.com", "username": "user"}}
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
get_server_config(config, "incomplete")
|
||||
assert "password" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_default_config_path():
|
||||
"""Test default config path is correct."""
|
||||
path = get_default_config_path()
|
||||
assert path.name == "config.toml"
|
||||
assert ".config" in str(path)
|
||||
assert "zzpyjenkins" in str(path)
|
||||
|
||||
|
||||
def test_full_config_flow():
|
||||
"""Test complete config loading flow with temp file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write("""
|
||||
[work]
|
||||
url = "https://jenkins.example.com"
|
||||
username = "testuser"
|
||||
password = "testpass"
|
||||
""")
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
config = load_config(temp_path)
|
||||
url, username, password = get_server_config(config, "work")
|
||||
|
||||
assert url == "https://jenkins.example.com"
|
||||
assert username == "testuser"
|
||||
assert password == "testpass"
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
Reference in New Issue
Block a user