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/sdk/config.py ADDED
@@ -0,0 +1,227 @@
1
+ """Configuration management for jcli.
2
+
3
+ Handles loading/saving YAML config, profile CRUD, and environment variable overrides.
4
+ """
5
+
6
+ import copy
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+
14
+ DEFAULT_CONFIG_DIR = Path.home() / ".jcli"
15
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.yaml"
16
+
17
+ DEFAULT_PROFILE_NAME = "default"
18
+
19
+ # Environment variable names
20
+ ENV_URL = "JCLI_URL"
21
+ ENV_USERNAME = "JCLI_USERNAME"
22
+ ENV_API_TOKEN = "JCLI_API_TOKEN"
23
+ ENV_PROFILE = "JCLI_PROFILE"
24
+
25
+ # Profile field names
26
+ FIELD_URL = "url"
27
+ FIELD_USERNAME = "username"
28
+ FIELD_API_TOKEN = "api_token"
29
+ FIELD_DESCRIPTION = "description"
30
+
31
+ PROFILE_FIELDS = (FIELD_URL, FIELD_USERNAME, FIELD_API_TOKEN, FIELD_DESCRIPTION)
32
+
33
+ DEFAULT_CONFIG_TEMPLATE: dict[str, Any] = {
34
+ "active_profile": DEFAULT_PROFILE_NAME,
35
+ "profiles": {
36
+ DEFAULT_PROFILE_NAME: {
37
+ FIELD_URL: "https://jenkins.example.com",
38
+ FIELD_USERNAME: "admin",
39
+ FIELD_API_TOKEN: "your-api-token-here",
40
+ FIELD_DESCRIPTION: "Default Jenkins instance",
41
+ }
42
+ },
43
+ }
44
+
45
+
46
+ class ConfigError(Exception):
47
+ """Raised when configuration operations fail."""
48
+
49
+
50
+ class ProfileNotFoundError(ConfigError):
51
+ """Raised when a requested profile does not exist."""
52
+
53
+
54
+ class Config:
55
+ """Jenkins CLI configuration manager.
56
+
57
+ Manages profiles (url, username, api_token, description) stored in YAML.
58
+ Environment variables (JCLI_URL, JCLI_USERNAME, JCLI_API_TOKEN, JCLI_PROFILE)
59
+ override config file values when present.
60
+ """
61
+
62
+ def __init__(self, config_path: str | Path | None = None) -> None:
63
+ self._config_path = Path(config_path) if config_path else DEFAULT_CONFIG_FILE
64
+ self._data: dict[str, Any] = {}
65
+ self._loaded = False
66
+
67
+ @property
68
+ def config_path(self) -> Path:
69
+ return self._config_path
70
+
71
+ def load(self) -> "Config":
72
+ """Load config from disk. Creates default config if file doesn't exist.
73
+
74
+ Returns:
75
+ self, for chaining.
76
+ """
77
+ if self._config_path.exists():
78
+ with open(self._config_path, "r", encoding="utf-8") as f:
79
+ loaded = yaml.safe_load(f)
80
+ if loaded and isinstance(loaded, dict):
81
+ self._data = loaded
82
+ else:
83
+ self._data = copy.deepcopy(DEFAULT_CONFIG_TEMPLATE)
84
+ else:
85
+ self._data = copy.deepcopy(DEFAULT_CONFIG_TEMPLATE)
86
+ self.save()
87
+
88
+ self._loaded = True
89
+ return self
90
+
91
+ def save(self) -> None:
92
+ """Persist current config to disk, creating parent dirs as needed."""
93
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
94
+ with open(self._config_path, "w", encoding="utf-8") as f:
95
+ yaml.dump(self._data, f, default_flow_style=False, allow_unicode=True)
96
+
97
+ def _ensure_loaded(self) -> None:
98
+ if not self._loaded:
99
+ self.load()
100
+
101
+ def _get_profiles(self) -> dict[str, dict[str, str]]:
102
+ self._ensure_loaded()
103
+ profiles = self._data.get("profiles", {})
104
+ if not isinstance(profiles, dict):
105
+ return {}
106
+ return profiles
107
+
108
+ def _apply_env_overrides(self, profile_data: dict[str, str]) -> dict[str, str]:
109
+ """Apply environment variable overrides to a profile's data.
110
+
111
+ Environment variables take precedence over config file values.
112
+ """
113
+ result = dict(profile_data)
114
+
115
+ env_url = os.environ.get(ENV_URL)
116
+ if env_url:
117
+ result[FIELD_URL] = env_url
118
+
119
+ env_username = os.environ.get(ENV_USERNAME)
120
+ if env_username:
121
+ result[FIELD_USERNAME] = env_username
122
+
123
+ env_token = os.environ.get(ENV_API_TOKEN)
124
+ if env_token:
125
+ result[FIELD_API_TOKEN] = env_token
126
+
127
+ return result
128
+
129
+ def get_active_profile_name(self) -> str:
130
+ """Return the active profile name (env override > config file > default)."""
131
+ self._ensure_loaded()
132
+ env_profile = os.environ.get(ENV_PROFILE)
133
+ if env_profile:
134
+ return env_profile
135
+ return self._data.get("active_profile", DEFAULT_PROFILE_NAME)
136
+
137
+ def get_active_profile(self) -> dict[str, str]:
138
+ """Return the active profile data with env overrides applied.
139
+
140
+ Raises:
141
+ ProfileNotFoundError: if the active profile doesn't exist.
142
+ """
143
+ name = self.get_active_profile_name()
144
+ return self.get_profile(name)
145
+
146
+ def list_profiles(self) -> dict[str, dict[str, str]]:
147
+ """Return all profiles (without env overrides)."""
148
+ return self._get_profiles()
149
+
150
+ def get_profile(self, name: str) -> dict[str, str]:
151
+ """Return a specific profile with env overrides applied.
152
+
153
+ Args:
154
+ name: Profile name.
155
+
156
+ Returns:
157
+ Profile data dict with keys: url, username, api_token, description.
158
+
159
+ Raises:
160
+ ProfileNotFoundError: if profile doesn't exist.
161
+ """
162
+ profiles = self._get_profiles()
163
+ if name not in profiles:
164
+ raise ProfileNotFoundError(f"Profile '{name}' not found")
165
+ return self._apply_env_overrides(profiles[name])
166
+
167
+ def add_profile(
168
+ self,
169
+ name: str,
170
+ url: str,
171
+ username: str,
172
+ api_token: str,
173
+ description: str = "",
174
+ ) -> None:
175
+ """Create or update a profile.
176
+
177
+ Args:
178
+ name: Profile name.
179
+ url: Jenkins server URL.
180
+ username: Jenkins username.
181
+ api_token: Jenkins API token.
182
+ description: Optional description.
183
+ """
184
+ self._ensure_loaded()
185
+ profiles = self._data.setdefault("profiles", {})
186
+ profiles[name] = {
187
+ FIELD_URL: url,
188
+ FIELD_USERNAME: username,
189
+ FIELD_API_TOKEN: api_token,
190
+ FIELD_DESCRIPTION: description,
191
+ }
192
+ self.save()
193
+
194
+ def remove_profile(self, name: str) -> None:
195
+ """Remove a profile.
196
+
197
+ Args:
198
+ name: Profile name to remove.
199
+
200
+ Raises:
201
+ ProfileNotFoundError: if profile doesn't exist.
202
+ """
203
+ self._ensure_loaded()
204
+ profiles = self._data.get("profiles", {})
205
+ if name not in profiles:
206
+ raise ProfileNotFoundError(f"Profile '{name}' not found")
207
+ del profiles[name]
208
+ # If we removed the active profile, reset to default
209
+ if self._data.get("active_profile") == name:
210
+ self._data["active_profile"] = DEFAULT_PROFILE_NAME
211
+ self.save()
212
+
213
+ def set_active_profile(self, name: str) -> None:
214
+ """Set the active profile.
215
+
216
+ Args:
217
+ name: Profile name to activate.
218
+
219
+ Raises:
220
+ ProfileNotFoundError: if profile doesn't exist.
221
+ """
222
+ self._ensure_loaded()
223
+ profiles = self._data.get("profiles", {})
224
+ if name not in profiles:
225
+ raise ProfileNotFoundError(f"Profile '{name}' not found")
226
+ self._data["active_profile"] = name
227
+ self.save()
jcli/sdk/credential.py ADDED
@@ -0,0 +1,143 @@
1
+ """Jenkins Credential SDK.
2
+
3
+ Provides functions for managing Jenkins credentials via the REST API.
4
+ Default store is ``system``, default domain is ``_`` (global domain).
5
+
6
+ API reference:
7
+ - List: GET /credentials/store/{store}/domain/{domain}/api/json
8
+ - Get: GET /credentials/store/{store}/domain/_/credential/{id}/api/json
9
+ - Create: POST /credentials/store/{store}/domain/{domain}/createCredentials
10
+ - Update: POST /credentials/store/{store}/domain/_/credential/{id}/updateCredentials
11
+ - Delete: POST /credentials/store/{store}/domain/{domain}/credential/{id}/doDelete
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from jcli.sdk.client import JenkinsClient
19
+
20
+
21
+ DEFAULT_STORE = "system"
22
+ DEFAULT_DOMAIN = "_"
23
+
24
+
25
+ def _build_base_path(store: str, domain: str) -> str:
26
+ """Build the base path for credential store/domain operations."""
27
+ return f"/credentials/store/{store}/domain/{domain}"
28
+
29
+
30
+ def list_credentials(
31
+ client: JenkinsClient,
32
+ store: str = DEFAULT_STORE,
33
+ domain: str = DEFAULT_DOMAIN,
34
+ depth: int | None = None,
35
+ ) -> Any:
36
+ """List all credentials in a store/domain.
37
+
38
+ Args:
39
+ client: Jenkins API client.
40
+ store: Credential store ID (default: ``system``).
41
+ domain: Credential domain ID (default: ``_``).
42
+ depth: Optional API depth parameter for nested expansion.
43
+
44
+ Returns:
45
+ Parsed JSON response from Jenkins.
46
+ """
47
+ path = f"{_build_base_path(store, domain)}/api/json"
48
+ params: dict[str, Any] = {}
49
+ if depth is not None:
50
+ params["depth"] = str(depth)
51
+ return client.get_json(path, params=params)
52
+
53
+
54
+ def get_credential(
55
+ client: JenkinsClient,
56
+ cred_id: str,
57
+ store: str = DEFAULT_STORE,
58
+ ) -> Any:
59
+ """Get details of a single credential.
60
+
61
+ Args:
62
+ client: Jenkins API client.
63
+ cred_id: Credential ID.
64
+ store: Credential store ID (default: ``system``).
65
+
66
+ Returns:
67
+ Parsed JSON response containing credential details.
68
+ """
69
+ path = (
70
+ f"{_build_base_path(store, DEFAULT_DOMAIN)}"
71
+ f"/credential/{cred_id}/api/json"
72
+ )
73
+ return client.get_json(path)
74
+
75
+
76
+ def create_credential(
77
+ client: JenkinsClient,
78
+ xml_data: str | bytes,
79
+ store: str = DEFAULT_STORE,
80
+ domain: str = DEFAULT_DOMAIN,
81
+ ) -> Any:
82
+ """Create a new credential from XML config.
83
+
84
+ The XML must follow the Jenkins credentials XStream format for the
85
+ target credential type (e.g. ``UsernamePasswordCredentialsImpl``).
86
+
87
+ Args:
88
+ client: Jenkins API client.
89
+ xml_data: Credential XML config (str or bytes).
90
+ store: Credential store ID (default: ``system``).
91
+ domain: Credential domain ID (default: ``_``).
92
+
93
+ Returns:
94
+ ``requests.Response`` from the POST request.
95
+ """
96
+ path = f"{_build_base_path(store, domain)}/createCredentials"
97
+ return client.post_xml(path, xml_data)
98
+
99
+
100
+ def update_credential(
101
+ client: JenkinsClient,
102
+ cred_id: str,
103
+ xml_data: str | bytes,
104
+ store: str = DEFAULT_STORE,
105
+ ) -> Any:
106
+ """Update an existing credential.
107
+
108
+ Args:
109
+ client: Jenkins API client.
110
+ cred_id: Credential ID to update.
111
+ xml_data: New credential XML config.
112
+ store: Credential store ID (default: ``system``).
113
+
114
+ Returns:
115
+ ``requests.Response`` from the POST request.
116
+ """
117
+ path = (
118
+ f"{_build_base_path(store, DEFAULT_DOMAIN)}"
119
+ f"/credential/{cred_id}/updateCredentials"
120
+ )
121
+ return client.post_xml(path, xml_data)
122
+
123
+
124
+ def delete_credential(
125
+ client: JenkinsClient,
126
+ cred_id: str,
127
+ store: str = DEFAULT_STORE,
128
+ ) -> Any:
129
+ """Delete a credential.
130
+
131
+ Args:
132
+ client: Jenkins API client.
133
+ cred_id: Credential ID to delete.
134
+ store: Credential store ID (default: ``system``).
135
+
136
+ Returns:
137
+ ``requests.Response`` from the POST request.
138
+ """
139
+ path = (
140
+ f"{_build_base_path(store, DEFAULT_DOMAIN)}"
141
+ f"/credential/{cred_id}/doDelete"
142
+ )
143
+ return client.post_data(path, data={})
jcli/sdk/exceptions.py ADDED
@@ -0,0 +1,59 @@
1
+ """Jenkins SDK exception hierarchy.
2
+
3
+ All exceptions inherit from JenkinsError, so callers can catch broadly or narrowly.
4
+ """
5
+
6
+
7
+ class JenkinsError(Exception):
8
+ """Base exception for all Jenkins-related errors."""
9
+
10
+ def __init__(self, message: str = "Jenkins error") -> None:
11
+ super().__init__(message)
12
+ self.message = message
13
+
14
+
15
+ class JenkinsAuthError(JenkinsError):
16
+ """Raised when authentication fails (HTTP 401)."""
17
+
18
+ def __init__(self, message: str = "Authentication failed (401)") -> None:
19
+ super().__init__(message)
20
+
21
+
22
+ class JenkinsNotFoundError(JenkinsError):
23
+ """Raised when a resource is not found (HTTP 404)."""
24
+
25
+ def __init__(self, message: str = "Resource not found (404)") -> None:
26
+ super().__init__(message)
27
+
28
+
29
+ class JenkinsConnectionError(JenkinsError):
30
+ """Raised when connection to Jenkins fails or times out."""
31
+
32
+ def __init__(self, message: str = "Connection failed") -> None:
33
+ super().__init__(message)
34
+
35
+
36
+ class JenkinsAPIError(JenkinsError):
37
+ """Raised for non-401/404 API errors.
38
+
39
+ Attributes:
40
+ status_code: HTTP status code from the response.
41
+ """
42
+
43
+ def __init__(self, message: str = "API error", status_code: int | None = None) -> None:
44
+ super().__init__(message)
45
+ self.status_code = status_code
46
+
47
+
48
+ class JenkinsConfigError(JenkinsError):
49
+ """Raised for configuration errors (invalid URL, missing credentials, etc.)."""
50
+
51
+ def __init__(self, message: str = "Configuration error") -> None:
52
+ super().__init__(message)
53
+
54
+
55
+ class JenkinsCrumbError(JenkinsError):
56
+ """Raised when Crumb (CSRF token) fetch fails."""
57
+
58
+ def __init__(self, message: str = "Crumb fetch failed") -> None:
59
+ super().__init__(message)
jcli/sdk/job.py ADDED
@@ -0,0 +1,191 @@
1
+ """Jenkins Job SDK — CRUD and lifecycle operations for Jenkins jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from jcli.sdk.client import JenkinsClient
8
+
9
+
10
+ def list_jobs(client: JenkinsClient, depth: int = 0) -> list[dict[str, Any]]:
11
+ """List all jobs with name, url, and color.
12
+
13
+ Args:
14
+ client: Authenticated JenkinsClient instance.
15
+ depth: Recursion depth for folder jobs (0 = top-level only).
16
+
17
+ Returns:
18
+ List of job dicts with keys: name, url, color.
19
+ """
20
+ data = client.get_json("/api/json", tree="jobs[name,url,color]")
21
+ return data.get("jobs", [])
22
+
23
+
24
+ def get_job(client: JenkinsClient, name: str) -> dict[str, Any]:
25
+ """Get full details for a specific job.
26
+
27
+ Args:
28
+ client: Authenticated JenkinsClient instance.
29
+ name: Job name (URL-encoding is handled by the client).
30
+
31
+ Returns:
32
+ Job details dict as returned by the Jenkins REST API.
33
+
34
+ Raises:
35
+ JenkinsNotFoundError: If the job does not exist.
36
+ """
37
+ return client.get_json(f"/job/{name}/api/json")
38
+
39
+
40
+ def create_job(client: JenkinsClient, name: str, config_xml: str) -> None:
41
+ """Create a new job from an XML configuration string.
42
+
43
+ Args:
44
+ client: Authenticated JenkinsClient instance.
45
+ name: Name for the new job.
46
+ config_xml: Jenkins job config XML as a string.
47
+
48
+ Raises:
49
+ JenkinsAPIError: On creation failure (e.g. duplicate name, bad XML).
50
+ """
51
+ client.post_xml(f"/createItem?name={name}", config_xml)
52
+
53
+
54
+ def delete_job(client: JenkinsClient, name: str) -> None:
55
+ """Delete a job.
56
+
57
+ Args:
58
+ client: Authenticated JenkinsClient instance.
59
+ name: Name of the job to delete.
60
+
61
+ Raises:
62
+ JenkinsNotFoundError: If the job does not exist.
63
+ """
64
+ client.post_data(f"/job/{name}/doDelete", data={})
65
+
66
+
67
+ def copy_job(client: JenkinsClient, from_name: str, new_name: str) -> None:
68
+ """Copy an existing job to a new name.
69
+
70
+ Args:
71
+ client: Authenticated JenkinsClient instance.
72
+ from_name: Source job name.
73
+ new_name: Name for the copied job.
74
+
75
+ Raises:
76
+ JenkinsNotFoundError: If the source job does not exist.
77
+ """
78
+ client.post_data(
79
+ "/createItem",
80
+ data={"name": new_name, "mode": "copy", "from": from_name},
81
+ )
82
+
83
+
84
+ def rename_job(
85
+ client: "JenkinsClient",
86
+ old_name: str,
87
+ new_name: str,
88
+ ) -> Any:
89
+ """Rename a job.
90
+
91
+ Args:
92
+ client: Jenkins API client.
93
+ old_name: Current job name.
94
+ new_name: New job name.
95
+
96
+ Returns:
97
+ Response from Jenkins (redirect on success).
98
+
99
+ Raises:
100
+ JenkinsNotFoundError: If the job does not exist.
101
+ """
102
+ path = f"/job/{old_name}/doRename"
103
+ return client.request("POST", path, params={"newName": new_name})
104
+
105
+
106
+ def enable_job(client: JenkinsClient, name: str) -> None:
107
+ """Enable a disabled job.
108
+
109
+ Args:
110
+ client: Authenticated JenkinsClient instance.
111
+ name: Name of the job to enable.
112
+
113
+ Raises:
114
+ JenkinsNotFoundError: If the job does not exist.
115
+ """
116
+ client.post_data(f"/job/{name}/enable", data={})
117
+
118
+
119
+ def disable_job(client: JenkinsClient, name: str) -> None:
120
+ """Disable a job.
121
+
122
+ Args:
123
+ client: Authenticated JenkinsClient instance.
124
+ name: Name of the job to disable.
125
+
126
+ Raises:
127
+ JenkinsNotFoundError: If the job does not exist.
128
+ """
129
+ client.post_data(f"/job/{name}/disable", data={})
130
+
131
+
132
+ def update_job_config(
133
+ client: JenkinsClient,
134
+ name: str,
135
+ config_xml: str,
136
+ ) -> Any:
137
+ """Update a job's configuration.
138
+
139
+ Args:
140
+ client: Jenkins API client.
141
+ name: Job name.
142
+ config_xml: New XML configuration.
143
+
144
+ Returns:
145
+ Response from Jenkins.
146
+ """
147
+ return client.post_xml(f"/job/{name}/config.xml", config_xml)
148
+
149
+
150
+ def get_job_config(client: JenkinsClient, name: str) -> str:
151
+ """Retrieve the XML configuration for a job.
152
+
153
+ Args:
154
+ client: Authenticated JenkinsClient instance.
155
+ name: Name of the job.
156
+
157
+ Returns:
158
+ Job configuration as an XML string.
159
+
160
+ Raises:
161
+ JenkinsNotFoundError: If the job does not exist.
162
+ """
163
+ resp = client.request("GET", f"/job/{name}/config.xml")
164
+ return resp.text
165
+
166
+
167
+ def create_folder(client: JenkinsClient, name: str) -> Any:
168
+ """Create a Jenkins folder.
169
+
170
+ Args:
171
+ client: Jenkins API client.
172
+ name: Folder name.
173
+
174
+ Returns:
175
+ Response from Jenkins.
176
+ """
177
+ path = f"/createItem?name={name}&mode=com.cloudbees.hudson.plugins.folder.Folder"
178
+ return client.request("POST", path)
179
+
180
+
181
+ def delete_folder(client: JenkinsClient, name: str) -> Any:
182
+ """Delete a Jenkins folder and all its contents.
183
+
184
+ Args:
185
+ client: Jenkins API client.
186
+ name: Folder name.
187
+
188
+ Returns:
189
+ Response from Jenkins.
190
+ """
191
+ return client.request("POST", f"/job/{name}/doDelete")