Compare commits

..

10 Commits

Author SHA1 Message Date
ba4bd785aa docs: update README with config file usage
Replace command-line credential options and environment variables
with config file documentation. Add the new `running` command and
update all examples to use the required --server option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:37:19 +08:00
d88cedc8a0 style: format and lint code 2026-03-18 14:03:20 +08:00
f1a75cab8c docs: update CLAUDE.md with config file usage 2026-03-18 14:02:47 +08:00
ddc175edc3 test: add integration test for config flow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:01:46 +08:00
ec5dda61ef feat: replace credential options with --server config option
- Replace --url, --username, --password CLI options with single --server option
- Server credentials are now read from config file (~/.config/zzpyjenkins/config.toml)
- Remove os and typing imports (no longer needed)
- Update type hints to use modern str | None syntax (Python 3.11+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:55:45 +08:00
1cb699820f feat: add get_default_config_path function
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:48:37 +08:00
03ba3dc88e feat: add get_server_config function with validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:43:09 +08:00
c1258bda3e feat: add config module with load_config function
Add TOML configuration file support with:
- ConfigError exception class for config-related errors
- load_config() function to parse TOML files with proper error handling
- Tests for missing file and invalid TOML syntax scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:37:00 +08:00
dfe91e2ce1 fix: Address plan review feedback - imports and test coverage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:27:44 +08:00
3664e98801 docs: Add config file support implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:25:06 +08:00
7 changed files with 766 additions and 55 deletions

View File

@@ -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
```

View File

@@ -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

View 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 |

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
# Tests package

100
tests/test_config.py Normal file
View 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()