python-jcli 0.1.0__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.
- jcli/__init__.py +1 -0
- jcli/__main__.py +3 -0
- jcli/cli.py +79 -0
- jcli/cli_helpers.py +58 -0
- jcli/plugins/__init__.py +28 -0
- jcli/plugins/build.py +197 -0
- jcli/plugins/config.py +268 -0
- jcli/plugins/credential.py +210 -0
- jcli/plugins/job.py +425 -0
- jcli/plugins/node.py +134 -0
- jcli/plugins/pipeline.py +108 -0
- jcli/plugins/plugin.py +173 -0
- jcli/plugins/skills.py +399 -0
- jcli/plugins/system.py +173 -0
- jcli/plugins/view.py +148 -0
- jcli/sdk/__init__.py +0 -0
- jcli/sdk/build.py +182 -0
- jcli/sdk/client.py +264 -0
- jcli/sdk/config.py +227 -0
- jcli/sdk/credential.py +143 -0
- jcli/sdk/exceptions.py +59 -0
- jcli/sdk/job.py +191 -0
- jcli/sdk/job_templates.py +128 -0
- jcli/sdk/node.py +139 -0
- jcli/sdk/output/__init__.py +3 -0
- jcli/sdk/output/formatter.py +131 -0
- jcli/sdk/pipeline.py +121 -0
- jcli/sdk/plugin.py +155 -0
- jcli/sdk/system.py +202 -0
- jcli/sdk/view.py +94 -0
- jcli/skills/jcli/SKILL.md +73 -0
- jcli/skills/jcli/config/SKILL.md +81 -0
- jcli/skills/jcli/credential/SKILL.md +47 -0
- jcli/skills/jcli/job/SKILL.md +55 -0
- jcli/skills/jcli/node/SKILL.md +51 -0
- jcli/skills/jcli/pipeline/SKILL.md +58 -0
- jcli/skills/jcli/plugin/SKILL.md +51 -0
- jcli/skills/jcli/system/SKILL.md +70 -0
- jcli/skills/jcli/view/SKILL.md +47 -0
- python_jcli-0.1.0.dist-info/METADATA +311 -0
- python_jcli-0.1.0.dist-info/RECORD +44 -0
- python_jcli-0.1.0.dist-info/WHEEL +5 -0
- python_jcli-0.1.0.dist-info/entry_points.txt +2 -0
- python_jcli-0.1.0.dist-info/top_level.txt +1 -0
jcli/plugins/view.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Jenkins View management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from jcli.cli_helpers import get_client, get_formatter
|
|
11
|
+
from jcli.sdk.view import create_view as sdk_create_view
|
|
12
|
+
from jcli.sdk.view import delete_view as sdk_delete_view
|
|
13
|
+
from jcli.sdk.view import get_view as sdk_get_view
|
|
14
|
+
from jcli.sdk.view import list_views as sdk_list_views
|
|
15
|
+
from jcli.sdk.view import update_view as sdk_update_view
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_output(ctx: click.Context, data: object) -> str:
|
|
19
|
+
"""Format data according to the user-specified output format."""
|
|
20
|
+
ctx_obj = ctx.obj or {}
|
|
21
|
+
fmt = ctx_obj.get("format", "table")
|
|
22
|
+
|
|
23
|
+
if fmt == "json":
|
|
24
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
25
|
+
|
|
26
|
+
if fmt == "yaml":
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
return yaml.dump(data, default_flow_style=False, allow_unicode=True)
|
|
30
|
+
|
|
31
|
+
# Default: table-like plain text
|
|
32
|
+
return str(data)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Click command group
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group("view", help="Manage Jenkins views.")
|
|
41
|
+
def view_group():
|
|
42
|
+
"""View management commands."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@view_group.command("list")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def view_list(ctx: click.Context) -> None:
|
|
48
|
+
"""List all Jenkins views."""
|
|
49
|
+
client = get_client(ctx)
|
|
50
|
+
views = sdk_list_views(client)
|
|
51
|
+
|
|
52
|
+
# Build table output
|
|
53
|
+
ctx_obj = ctx.obj or {}
|
|
54
|
+
if ctx_obj.get("format", "table") == "table":
|
|
55
|
+
if not views:
|
|
56
|
+
click.echo("No views found.")
|
|
57
|
+
return
|
|
58
|
+
for v in views:
|
|
59
|
+
job_count = len(v.get("jobs", []))
|
|
60
|
+
click.echo(f" {v['name']} ({job_count} jobs)")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
click.echo(_format_output(ctx, views))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@view_group.command("get")
|
|
67
|
+
@click.argument("view_name", metavar="NAME")
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def view_get(ctx: click.Context, view_name: str) -> None:
|
|
70
|
+
"""Get details for a Jenkins view."""
|
|
71
|
+
client = get_client(ctx)
|
|
72
|
+
data = sdk_get_view(client, view_name)
|
|
73
|
+
|
|
74
|
+
# Table output: show key fields
|
|
75
|
+
ctx_obj = ctx.obj or {}
|
|
76
|
+
if ctx_obj.get("format", "table") == "table":
|
|
77
|
+
click.echo(f"Name: {data.get('name', view_name)}")
|
|
78
|
+
click.echo(f"URL: {data.get('url', '')}")
|
|
79
|
+
click.echo(f"Description: {data.get('description', '')}")
|
|
80
|
+
jobs = data.get("jobs", [])
|
|
81
|
+
if jobs:
|
|
82
|
+
click.echo("Jobs:")
|
|
83
|
+
for j in jobs:
|
|
84
|
+
click.echo(f" - {j['name']} [{j.get('color', 'unknown')}]")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
click.echo(_format_output(ctx, data))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@view_group.command("create")
|
|
91
|
+
@click.argument("view_name", metavar="NAME")
|
|
92
|
+
@click.argument(
|
|
93
|
+
"config_file",
|
|
94
|
+
metavar="CONFIG_XML",
|
|
95
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
96
|
+
)
|
|
97
|
+
@click.pass_context
|
|
98
|
+
def view_create(ctx: click.Context, view_name: str, config_file: Path) -> None:
|
|
99
|
+
"""Create a new Jenkins view from an XML config file."""
|
|
100
|
+
xml_data = config_file.read_text(encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
client = get_client(ctx)
|
|
103
|
+
sdk_create_view(client, view_name, xml_data)
|
|
104
|
+
click.echo(f"View '{view_name}' created successfully.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@view_group.command("update")
|
|
108
|
+
@click.argument("view_name")
|
|
109
|
+
@click.argument("config_file", type=click.Path(exists=True, dir_okay=False))
|
|
110
|
+
@click.pass_context
|
|
111
|
+
def update_view_cmd(ctx, view_name, config_file):
|
|
112
|
+
"""Update a view's configuration from an XML file."""
|
|
113
|
+
client = get_client(ctx)
|
|
114
|
+
fmt = get_formatter(ctx)
|
|
115
|
+
try:
|
|
116
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
117
|
+
config_xml = f.read()
|
|
118
|
+
sdk_update_view(client, view_name, config_xml)
|
|
119
|
+
fmt.print_success(f"View '{view_name}' updated.")
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
fmt.print_error(str(exc))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@view_group.command("delete")
|
|
125
|
+
@click.argument("view_name", metavar="NAME")
|
|
126
|
+
@click.option(
|
|
127
|
+
"--yes",
|
|
128
|
+
"-y",
|
|
129
|
+
is_flag=True,
|
|
130
|
+
help="Skip confirmation prompt.",
|
|
131
|
+
)
|
|
132
|
+
@click.pass_context
|
|
133
|
+
def view_delete(ctx: click.Context, view_name: str, yes: bool) -> None:
|
|
134
|
+
"""Delete a Jenkins view."""
|
|
135
|
+
if not yes:
|
|
136
|
+
click.confirm(
|
|
137
|
+
f"Are you sure you want to delete view '{view_name}'?",
|
|
138
|
+
abort=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
client = get_client(ctx)
|
|
142
|
+
sdk_delete_view(client, view_name)
|
|
143
|
+
click.echo(f"View '{view_name}' deleted successfully.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register(parent_group: click.Group) -> None:
|
|
147
|
+
"""Register the view subgroup under the parent Click group."""
|
|
148
|
+
parent_group.add_command(view_group)
|
jcli/sdk/__init__.py
ADDED
|
File without changes
|
jcli/sdk/build.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Jenkins Build SDK — trigger, query, stop builds and manage queue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from jcli.sdk.client import JenkinsClient
|
|
10
|
+
from jcli.sdk.exceptions import JenkinsError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _job_path(job_name: str) -> str:
|
|
14
|
+
"""Convert a job name to a Jenkins URL path segment.
|
|
15
|
+
|
|
16
|
+
Handles nested jobs (folder/sub-job) by converting each slash-separated
|
|
17
|
+
segment into ``/job/<segment>``.
|
|
18
|
+
"""
|
|
19
|
+
segments = job_name.split("/")
|
|
20
|
+
return "/job/" + "/job/".join(segments)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trigger_build(
|
|
24
|
+
client: JenkinsClient,
|
|
25
|
+
job_name: str,
|
|
26
|
+
params: dict[str, Any] | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Trigger a new build for the given job.
|
|
29
|
+
|
|
30
|
+
If *params* is provided the build is triggered with parameters via
|
|
31
|
+
``/buildWithParameters``, otherwise a plain ``/build`` is used.
|
|
32
|
+
Jenkins returns a ``Location`` header pointing to the queued item.
|
|
33
|
+
This function follows that redirect and parses the queue-item JSON.
|
|
34
|
+
|
|
35
|
+
:returns: Queue item info as returned by Jenkins (dict).
|
|
36
|
+
"""
|
|
37
|
+
path = _job_path(job_name)
|
|
38
|
+
if params:
|
|
39
|
+
url = f"{path}/buildWithParameters"
|
|
40
|
+
resp = client.request("POST", url, params=params, allow_redirects=False)
|
|
41
|
+
else:
|
|
42
|
+
url = f"{path}/build"
|
|
43
|
+
resp = client.request("POST", url, allow_redirects=False)
|
|
44
|
+
|
|
45
|
+
# Jenkins responds with 201 Created or 302 Found and a Location header
|
|
46
|
+
# pointing to the queue item. Follow that URL to get the queue item JSON.
|
|
47
|
+
location = resp.headers.get("Location")
|
|
48
|
+
if location:
|
|
49
|
+
queue_path = urllib.parse.urlparse(location).path
|
|
50
|
+
# Strip the base URL prefix — client.get_json accepts an absolute path
|
|
51
|
+
if queue_path.startswith(client.base_url):
|
|
52
|
+
queue_path = queue_path[len(client.base_url) :]
|
|
53
|
+
try:
|
|
54
|
+
return client.get_json(queue_path + "api/json")
|
|
55
|
+
except JenkinsError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
# Fallback — return a minimal dict
|
|
59
|
+
return {"status": "queued", "url": location or ""}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_build(
|
|
63
|
+
client: JenkinsClient,
|
|
64
|
+
job_name: str,
|
|
65
|
+
number: int,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Get details for a specific build.
|
|
68
|
+
|
|
69
|
+
:returns: Build details as returned by the Jenkins API.
|
|
70
|
+
"""
|
|
71
|
+
path = f"{_job_path(job_name)}/{number}"
|
|
72
|
+
return client.get_json(f"{path}/api/json")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_builds(
|
|
76
|
+
client: JenkinsClient,
|
|
77
|
+
job_name: str,
|
|
78
|
+
limit: int = 10,
|
|
79
|
+
) -> list[dict[str, Any]]:
|
|
80
|
+
"""List recent builds for a job.
|
|
81
|
+
|
|
82
|
+
:param limit: Maximum number of builds to return (applied client-side).
|
|
83
|
+
:returns: List of build dicts (trimmed to *limit*).
|
|
84
|
+
"""
|
|
85
|
+
path = _job_path(job_name)
|
|
86
|
+
tree = f"builds[number,url,result,timestamp,duration,building]{f'{{{0},{limit - 1}}}' if limit > 0 else ''}"
|
|
87
|
+
data = client.get_json(f"{path}/api/json", tree=tree)
|
|
88
|
+
builds: list[dict[str, Any]] = data.get("builds", [])
|
|
89
|
+
return builds[:limit]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_build_log(
|
|
93
|
+
client: JenkinsClient,
|
|
94
|
+
job_name: str,
|
|
95
|
+
number: int,
|
|
96
|
+
start: int = 0,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Retrieve the console log for a build.
|
|
99
|
+
|
|
100
|
+
:param start: Byte offset to start reading from (useful for polling).
|
|
101
|
+
:returns: Console log text.
|
|
102
|
+
"""
|
|
103
|
+
path = f"{_job_path(job_name)}/{number}/consoleText"
|
|
104
|
+
resp = client.request("GET", path)
|
|
105
|
+
text = resp.text
|
|
106
|
+
if start > 0:
|
|
107
|
+
return text[start:]
|
|
108
|
+
return text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stop_build(
|
|
112
|
+
client: JenkinsClient,
|
|
113
|
+
job_name: str,
|
|
114
|
+
number: int,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Stop (abort) a running build.
|
|
117
|
+
|
|
118
|
+
:returns: Empty dict on success.
|
|
119
|
+
"""
|
|
120
|
+
path = f"{_job_path(job_name)}/{number}"
|
|
121
|
+
client.request("POST", f"{path}/stop")
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_queue(client: JenkinsClient) -> list[dict[str, Any]]:
|
|
126
|
+
"""Return all items currently in the Jenkins build queue.
|
|
127
|
+
|
|
128
|
+
:returns: List of queue item dicts.
|
|
129
|
+
"""
|
|
130
|
+
data = client.get_json("/queue/api/json")
|
|
131
|
+
return data.get("items", [])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cancel_queue_item(
|
|
135
|
+
client: JenkinsClient,
|
|
136
|
+
queue_id: int,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Cancel a queued item.
|
|
139
|
+
|
|
140
|
+
:param queue_id: Numeric queue item ID.
|
|
141
|
+
:returns: Empty dict on success.
|
|
142
|
+
"""
|
|
143
|
+
client.request("POST", "/queue/cancelItem", params={"id": queue_id})
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_build_artifacts(
|
|
148
|
+
client: JenkinsClient,
|
|
149
|
+
job_name: str,
|
|
150
|
+
build_number: int | str,
|
|
151
|
+
) -> Any:
|
|
152
|
+
"""Get build artifacts.
|
|
153
|
+
|
|
154
|
+
Lists artifacts produced by a build.
|
|
155
|
+
"""
|
|
156
|
+
path = _job_path(job_name) + f"/{build_number}/api/json"
|
|
157
|
+
return client.get_json(path, tree="artifacts[fileName,relativePath]")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def wait_for_build(
|
|
161
|
+
client: JenkinsClient,
|
|
162
|
+
job_name: str,
|
|
163
|
+
number: int,
|
|
164
|
+
timeout: int = 600,
|
|
165
|
+
poll_interval: int = 2,
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Poll until a build completes or *timeout* is reached.
|
|
168
|
+
|
|
169
|
+
:param timeout: Maximum seconds to wait.
|
|
170
|
+
:param poll_interval: Seconds between status checks.
|
|
171
|
+
:returns: Final build dict.
|
|
172
|
+
:raises TimeoutError: if the build does not finish within *timeout*.
|
|
173
|
+
"""
|
|
174
|
+
deadline = time.monotonic() + timeout
|
|
175
|
+
while time.monotonic() < deadline:
|
|
176
|
+
build = get_build(client, job_name, number)
|
|
177
|
+
if not build.get("building", False):
|
|
178
|
+
return build
|
|
179
|
+
time.sleep(poll_interval)
|
|
180
|
+
raise TimeoutError(
|
|
181
|
+
f"Build {job_name}#{number} did not complete within {timeout}s"
|
|
182
|
+
)
|
jcli/sdk/client.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Jenkins API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from jcli.sdk.exceptions import (
|
|
12
|
+
JenkinsAPIError,
|
|
13
|
+
JenkinsAuthError,
|
|
14
|
+
JenkinsConnectionError,
|
|
15
|
+
JenkinsCrumbError,
|
|
16
|
+
JenkinsNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Retry configuration
|
|
22
|
+
MAX_RETRIES = 1
|
|
23
|
+
RETRYABLE_STATUS_CODES = {503, 504}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JenkinsClient:
|
|
27
|
+
"""Lightweight Jenkins REST API client.
|
|
28
|
+
|
|
29
|
+
Supports automatic HTTP Basic Auth, Crumb (CSRF) handling,
|
|
30
|
+
and maps HTTP errors to typed exceptions.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
username: str = "",
|
|
37
|
+
token: str = "",
|
|
38
|
+
) -> None:
|
|
39
|
+
self.base_url = base_url.rstrip("/")
|
|
40
|
+
self.session = requests.Session()
|
|
41
|
+
if username and token:
|
|
42
|
+
self.session.auth = (username, token)
|
|
43
|
+
|
|
44
|
+
# Crumb cache: lazily fetched on first POST
|
|
45
|
+
self._crumb_header: str | None = None
|
|
46
|
+
self._crumb_value: str | None = None
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# URL helpers
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _url(self, path: str) -> str:
|
|
53
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Crumb (CSRF token) handling
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _fetch_crumb(self) -> None:
|
|
60
|
+
"""Fetch Jenkins Crumb from ``/crumbIssuer/api/json``.
|
|
61
|
+
|
|
62
|
+
Caches the header name and value for subsequent POST requests.
|
|
63
|
+
Raises ``JenkinsCrumbError`` if the fetch fails.
|
|
64
|
+
"""
|
|
65
|
+
url = self._url("/crumbIssuer/api/json")
|
|
66
|
+
try:
|
|
67
|
+
resp = self.session.get(url, timeout=10)
|
|
68
|
+
except requests.RequestException as exc:
|
|
69
|
+
raise JenkinsCrumbError(
|
|
70
|
+
f"Failed to connect for crumb: {exc}"
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
if resp.status_code == 404:
|
|
74
|
+
# Crumb issuer disabled – nothing to do
|
|
75
|
+
logger.debug("Crumb issuer not available (404), CSRF protection disabled")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if resp.status_code != 200:
|
|
79
|
+
raise JenkinsCrumbError(
|
|
80
|
+
f"Crumb fetch failed with status {resp.status_code}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
data = resp.json()
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise JenkinsCrumbError(
|
|
87
|
+
"Crumb response is not valid JSON"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
self._crumb_header = data.get("crumbRequestField")
|
|
91
|
+
self._crumb_value = data.get("crumb")
|
|
92
|
+
if not self._crumb_header or not self._crumb_value:
|
|
93
|
+
raise JenkinsCrumbError("Crumb response missing header or value")
|
|
94
|
+
|
|
95
|
+
logger.debug("Crumb fetched: %s", self._crumb_header)
|
|
96
|
+
|
|
97
|
+
def _ensure_crumb(self) -> dict[str, str]:
|
|
98
|
+
"""Return crumb headers, fetching if needed. Empty dict if unavailable."""
|
|
99
|
+
if self._crumb_header and self._crumb_value:
|
|
100
|
+
return {self._crumb_header: self._crumb_value}
|
|
101
|
+
|
|
102
|
+
self._fetch_crumb()
|
|
103
|
+
|
|
104
|
+
if self._crumb_header and self._crumb_value:
|
|
105
|
+
return {self._crumb_header: self._crumb_value}
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Error mapping
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _map_error(self, resp: requests.Response) -> None:
|
|
113
|
+
"""Raise a typed exception for error status codes."""
|
|
114
|
+
status = resp.status_code
|
|
115
|
+
if status == 401:
|
|
116
|
+
raise JenkinsAuthError(
|
|
117
|
+
f"Authentication failed (401) for {resp.url}"
|
|
118
|
+
)
|
|
119
|
+
if status == 404:
|
|
120
|
+
raise JenkinsNotFoundError(
|
|
121
|
+
f"Resource not found (404) for {resp.url}"
|
|
122
|
+
)
|
|
123
|
+
if status >= 400:
|
|
124
|
+
raise JenkinsAPIError(
|
|
125
|
+
f"API error {status} for {resp.url}",
|
|
126
|
+
status_code=status,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Core request method
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def request(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
path: str,
|
|
137
|
+
*,
|
|
138
|
+
params: dict[str, Any] | None = None,
|
|
139
|
+
headers: dict[str, str] | None = None,
|
|
140
|
+
data: Any = None,
|
|
141
|
+
json: Any = None,
|
|
142
|
+
timeout: int = 30,
|
|
143
|
+
retries: int = MAX_RETRIES,
|
|
144
|
+
**kwargs: Any,
|
|
145
|
+
) -> requests.Response:
|
|
146
|
+
"""Unified HTTP request with error mapping and retry on 503/504.
|
|
147
|
+
|
|
148
|
+
:param method: HTTP method (GET, POST, PUT, DELETE, …)
|
|
149
|
+
:param path: URL path relative to base_url
|
|
150
|
+
:param retries: Number of retries on 503/504 (default 1)
|
|
151
|
+
:returns: ``requests.Response``
|
|
152
|
+
:raises JenkinsAuthError: on 401
|
|
153
|
+
:raises JenkinsNotFoundError: on 404
|
|
154
|
+
:raises JenkinsAPIError: on other 4xx
|
|
155
|
+
:raises JenkinsConnectionError: on connection failures
|
|
156
|
+
"""
|
|
157
|
+
url = self._url(path)
|
|
158
|
+
req_headers: dict[str, str] = dict(headers) if headers else {}
|
|
159
|
+
|
|
160
|
+
# Add crumb for mutating methods
|
|
161
|
+
if method.upper() in ("POST", "PUT", "DELETE"):
|
|
162
|
+
crumb_headers = self._ensure_crumb()
|
|
163
|
+
req_headers.update(crumb_headers)
|
|
164
|
+
|
|
165
|
+
last_exc: Exception | None = None
|
|
166
|
+
for attempt in range(1 + retries):
|
|
167
|
+
try:
|
|
168
|
+
resp = self.session.request(
|
|
169
|
+
method,
|
|
170
|
+
url,
|
|
171
|
+
params=params,
|
|
172
|
+
headers=req_headers,
|
|
173
|
+
data=data,
|
|
174
|
+
json=json,
|
|
175
|
+
timeout=timeout,
|
|
176
|
+
**kwargs,
|
|
177
|
+
)
|
|
178
|
+
except requests.ConnectionError as exc:
|
|
179
|
+
raise JenkinsConnectionError(
|
|
180
|
+
f"Connection failed: {exc}"
|
|
181
|
+
) from exc
|
|
182
|
+
except requests.Timeout as exc:
|
|
183
|
+
raise JenkinsConnectionError(
|
|
184
|
+
f"Request timed out after {timeout}s"
|
|
185
|
+
) from exc
|
|
186
|
+
except requests.RequestException as exc:
|
|
187
|
+
raise JenkinsConnectionError(
|
|
188
|
+
f"Request failed: {exc}"
|
|
189
|
+
) from exc
|
|
190
|
+
|
|
191
|
+
if resp.status_code in RETRYABLE_STATUS_CODES and attempt < retries:
|
|
192
|
+
logger.warning(
|
|
193
|
+
"Retryable status %d on %s %s (attempt %d/%d)",
|
|
194
|
+
resp.status_code,
|
|
195
|
+
method,
|
|
196
|
+
path,
|
|
197
|
+
attempt + 1,
|
|
198
|
+
retries,
|
|
199
|
+
)
|
|
200
|
+
time.sleep(0.1)
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
self._map_error(resp)
|
|
204
|
+
return resp
|
|
205
|
+
|
|
206
|
+
# Should not reach here, but safety net
|
|
207
|
+
return resp # type: ignore[possibly-undefined]
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Convenience methods
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def get_json(
|
|
214
|
+
self,
|
|
215
|
+
path: str,
|
|
216
|
+
params: dict[str, Any] | None = None,
|
|
217
|
+
tree: str | None = None,
|
|
218
|
+
) -> Any:
|
|
219
|
+
"""GET request returning parsed JSON.
|
|
220
|
+
|
|
221
|
+
:param path: URL path relative to base_url
|
|
222
|
+
:param params: Query parameters
|
|
223
|
+
:param tree: Jenkins tree parameter for field filtering
|
|
224
|
+
:returns: Parsed JSON (dict or list)
|
|
225
|
+
"""
|
|
226
|
+
if tree:
|
|
227
|
+
params = dict(params) if params else {}
|
|
228
|
+
params["tree"] = tree
|
|
229
|
+
resp = self.request("GET", path, params=params)
|
|
230
|
+
return resp.json()
|
|
231
|
+
|
|
232
|
+
def post_xml(self, path: str, xml_data: str | bytes) -> requests.Response:
|
|
233
|
+
"""POST with ``application/xml`` content type.
|
|
234
|
+
|
|
235
|
+
:param path: URL path relative to base_url
|
|
236
|
+
:param xml_data: XML payload (str or bytes)
|
|
237
|
+
:returns: ``requests.Response``
|
|
238
|
+
"""
|
|
239
|
+
return self.request(
|
|
240
|
+
"POST",
|
|
241
|
+
path,
|
|
242
|
+
data=xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data,
|
|
243
|
+
headers={"Content-Type": "application/xml"},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def post_data(
|
|
247
|
+
self,
|
|
248
|
+
path: str,
|
|
249
|
+
data: dict[str, Any] | str | bytes,
|
|
250
|
+
content_type: str = "application/x-www-form-urlencoded",
|
|
251
|
+
) -> requests.Response:
|
|
252
|
+
"""POST with arbitrary content type.
|
|
253
|
+
|
|
254
|
+
:param path: URL path relative to base_url
|
|
255
|
+
:param data: Form data (dict), raw string, or bytes
|
|
256
|
+
:param content_type: Content-Type header (default form-encoded)
|
|
257
|
+
:returns: ``requests.Response``
|
|
258
|
+
"""
|
|
259
|
+
return self.request(
|
|
260
|
+
"POST",
|
|
261
|
+
path,
|
|
262
|
+
data=data,
|
|
263
|
+
headers={"Content-Type": content_type},
|
|
264
|
+
)
|