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/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
+ )