python-rundeck 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.
rundeck/base.py ADDED
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Bases objet/manager alignées sur le pattern python-gitlab,
5
+ adossées au client `_request`.
6
+ """
7
+
8
+ import pprint as _pprint
9
+ from typing import Any, Generic, Iterable, Iterator, TypeVar
10
+
11
+ from rundeck.const import HTTP_DELETE, HTTP_GET, HTTP_POST, HTTP_PUT
12
+
13
+ ResourceT = TypeVar("ResourceT", bound="RundeckObject")
14
+
15
+
16
+ class RundeckObject(Generic[ResourceT]):
17
+ """Objet de base représentant une ressource Rundeck."""
18
+
19
+ _id_attr: str = "id"
20
+ _repr_attr: str | None = None
21
+
22
+ def __init__(
23
+ self,
24
+ manager: "RundeckObjectManager[ResourceT]",
25
+ attrs: dict[str, Any],
26
+ ) -> None:
27
+ self.manager = manager
28
+ self.rd = manager.rd
29
+ self._attrs = attrs or {}
30
+
31
+ def __getattr__(self, name: str) -> Any:
32
+ try:
33
+ return self._attrs[name]
34
+ except KeyError:
35
+ raise AttributeError(name) from None
36
+
37
+ def __repr__(self) -> str:
38
+ value = None
39
+ if self._repr_attr and self._repr_attr in self._attrs:
40
+ value = self._attrs[self._repr_attr]
41
+ elif self._id_attr in self._attrs:
42
+ value = self._attrs[self._id_attr]
43
+ suffix = f" {value}" if value is not None else ""
44
+ return f"<{self.__class__.__name__}{suffix}>"
45
+
46
+ @property
47
+ def id(self) -> Any:
48
+ return self._attrs.get(self._id_attr)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return dict(self._attrs)
52
+
53
+ def refresh(self: ResourceT) -> ResourceT:
54
+ """Recharge la ressource en interrogeant son manager."""
55
+ ident = self._attrs.get(self._id_attr)
56
+ if ident is None:
57
+ raise AttributeError(f"{self.__class__.__name__} has no identifier")
58
+ fresh = self.manager.get(ident)
59
+ self._attrs = fresh._attrs
60
+ return self
61
+
62
+ def pformat(self) -> str:
63
+ """Retourne une représentation jolie du dictionnaire d'attributs."""
64
+ return _pprint.pformat(self.to_dict())
65
+
66
+ def pprint(self) -> None:
67
+ """Affiche la représentation jolie des attributs."""
68
+ _pprint.pprint(self.to_dict())
69
+
70
+
71
+ class RundeckObjectManager(Generic[ResourceT]):
72
+ """Manager de base fournissant helpers CRUD et wrapping."""
73
+
74
+ _path: str = ""
75
+ _obj_cls: type[ResourceT] = RundeckObject # type: ignore[assignment]
76
+
77
+ def __init__(self, rd: Any, parent: RundeckObject | None = None) -> None:
78
+ self.rd = rd
79
+ self.parent = parent
80
+
81
+ def _base_path(self) -> str:
82
+ base = self._path or ""
83
+ if "{parent}" in base:
84
+ if not self.parent:
85
+ raise ValueError("Parent requis pour ce manager")
86
+ base = base.format(parent=self.parent.id)
87
+ elif (
88
+ base
89
+ and not base.startswith("/")
90
+ and self.parent
91
+ and getattr(self.parent, "manager", None)
92
+ ):
93
+ # Cascade automatique : si _path est relatif, préfixer avec le chemin
94
+ # du manager parent.
95
+ parent_path = self.parent.manager._build_path(
96
+ getattr(self.parent, getattr(self.parent, "_id_attr", "id"), None)
97
+ )
98
+ base = (
99
+ f"{parent_path.rstrip('/')}/{base.lstrip('/')}" if base else parent_path
100
+ )
101
+ return base
102
+
103
+ def _build_path(self, suffix: str | None = None) -> str:
104
+ base = self._base_path()
105
+ if suffix:
106
+ if base.endswith("/"):
107
+ return f"{base}{suffix.lstrip('/')}"
108
+ if base:
109
+ return f"{base}/{suffix.lstrip('/')}"
110
+ return suffix
111
+ return base
112
+
113
+ def _request(
114
+ self,
115
+ method: str,
116
+ path: str,
117
+ params: dict[str, Any] | None = None,
118
+ json: dict[str, Any] | None = None,
119
+ data: dict[str, Any] | None = None,
120
+ headers: dict[str, str] | None = None,
121
+ **kwargs: Any,
122
+ ) -> Any:
123
+ return self.rd._request(
124
+ method=method,
125
+ path=path,
126
+ params=params,
127
+ json=json,
128
+ data=data,
129
+ headers=headers,
130
+ **kwargs,
131
+ )
132
+
133
+ def _wrap(self, data: dict[str, Any]) -> ResourceT:
134
+ return self._obj_cls(self, data)
135
+
136
+ def _wrap_list(self, items: Iterable[dict[str, Any]]) -> list[ResourceT]:
137
+ return [self._wrap(item) for item in items]
138
+
139
+ def iter(
140
+ self,
141
+ path: str | None = None,
142
+ params: dict[str, Any] | None = None,
143
+ page_size: int | None = None,
144
+ **kwargs: Any,
145
+ ) -> Iterator[ResourceT]:
146
+ """
147
+ Générateur paginé sur les ressources (offset/max).
148
+ """
149
+ target = path or self._build_path()
150
+ query: dict[str, Any] = dict(params or {})
151
+ offset = int(query.get("offset", 0))
152
+ if page_size:
153
+ query["max"] = page_size
154
+
155
+ while True:
156
+ current_params = dict(query)
157
+ current_params["offset"] = offset
158
+ page = self.rd.http_list(target, params=current_params, **kwargs)
159
+ if not page:
160
+ break
161
+ for item in page:
162
+ yield self._wrap(item)
163
+ if page_size and len(page) < page_size:
164
+ break
165
+ offset += len(page)
166
+
167
+ def _list(
168
+ self,
169
+ params: dict[str, Any] | None = None,
170
+ path: str | None = None,
171
+ **kwargs: Any,
172
+ ) -> list[ResourceT]:
173
+ target = path or self._build_path()
174
+ result = self.rd.http_list(target, params=params, **kwargs)
175
+ return self._wrap_list(result)
176
+
177
+ def _get(
178
+ self,
179
+ ident: str,
180
+ params: dict[str, Any] | None = None,
181
+ path: str | None = None,
182
+ **kwargs: Any,
183
+ ) -> ResourceT:
184
+ target = path or self._build_path(str(ident))
185
+ result = self.rd._request(HTTP_GET, target, params=params, **kwargs)
186
+ return self._wrap(result)
187
+
188
+ def _create(
189
+ self,
190
+ json: dict[str, Any] | None = None,
191
+ data: dict[str, Any] | None = None,
192
+ params: dict[str, Any] | None = None,
193
+ path: str | None = None,
194
+ **kwargs: Any,
195
+ ) -> ResourceT:
196
+ target = path or self._build_path()
197
+ result = self.rd._request(
198
+ HTTP_POST, target, json=json, data=data, params=params, **kwargs
199
+ )
200
+ return self._wrap(result)
201
+
202
+ def _update(
203
+ self,
204
+ ident: str,
205
+ json: dict[str, Any] | None = None,
206
+ data: dict[str, Any] | None = None,
207
+ params: dict[str, Any] | None = None,
208
+ path: str | None = None,
209
+ **kwargs: Any,
210
+ ) -> ResourceT:
211
+ target = path or self._build_path(str(ident))
212
+ result = self.rd._request(
213
+ HTTP_PUT, target, json=json, data=data, params=params, **kwargs
214
+ )
215
+ return self._wrap(result)
216
+
217
+ def _delete(
218
+ self,
219
+ ident: str,
220
+ params: dict[str, Any] | None = None,
221
+ path: str | None = None,
222
+ **kwargs: Any,
223
+ ) -> None:
224
+ target = path or self._build_path(str(ident))
225
+ self.rd._request(HTTP_DELETE, target, params=params, **kwargs)
226
+
227
+
228
+ __all__ = [
229
+ "RundeckObject",
230
+ "RundeckObjectManager",
231
+ ]
rundeck/client.py ADDED
@@ -0,0 +1,341 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Client principal pour l'API Rundeck
5
+ """
6
+
7
+ from typing import Any
8
+ from urllib.parse import urljoin
9
+
10
+ import requests
11
+
12
+ from rundeck.config import RundeckConfig
13
+ from rundeck.const import (
14
+ HTTP_DELETE,
15
+ HTTP_GET,
16
+ HTTP_POST,
17
+ HTTP_PUT,
18
+ RUNDECK_AUTH_HEADER,
19
+ )
20
+ from rundeck.exceptions import (
21
+ RundeckAuthenticationError,
22
+ RundeckConnectionError,
23
+ RundeckTimeoutError,
24
+ raise_for_status,
25
+ )
26
+
27
+
28
+ class Rundeck:
29
+ """Wrapper pour interagir avec l'API Rundeck."""
30
+
31
+ def __init__(
32
+ self,
33
+ url: str | None = None,
34
+ token: str | None = None,
35
+ username: str | None = None,
36
+ password: str | None = None,
37
+ api_version: int | None = None,
38
+ timeout: float | None = None,
39
+ ssl_verify: bool | None = None,
40
+ session: requests.Session | None = None,
41
+ config_files: list[str] | None = None,
42
+ ) -> None:
43
+ self.config = RundeckConfig(
44
+ url=url,
45
+ token=token,
46
+ username=username,
47
+ password=password,
48
+ api_version=api_version,
49
+ timeout=timeout,
50
+ ssl_verify=ssl_verify,
51
+ config_files=config_files,
52
+ )
53
+
54
+ if not self.config.url:
55
+ raise ValueError("URL Rundeck manquante (config ou argument requis).")
56
+
57
+ self._base_url = self.config.url.rstrip("/")
58
+ self._api_version = str(self.config.api_version)
59
+ self._api_url = f"{self._base_url}/api/{self._api_version}"
60
+
61
+ self.timeout = self.config.timeout
62
+ self.ssl_verify = self.config.ssl_verify
63
+
64
+ self.session = session or requests.Session()
65
+ self.headers = {
66
+ "User-Agent": getattr(self.config, "user_agent", None) or "python-rundeck",
67
+ "Accept": "application/json",
68
+ }
69
+ if self.config.token:
70
+ self.headers[RUNDECK_AUTH_HEADER] = self.config.token
71
+ self.session.headers.update(self.headers)
72
+ self.session.verify = self.ssl_verify
73
+
74
+ if not self.config.token and self.config.username and self.config.password:
75
+ self._authenticate_with_password()
76
+
77
+ self._init_managers()
78
+
79
+ def _init_managers(self) -> None:
80
+ """Initialise les managers d'objets."""
81
+ # Import paresseux pour éviter les boucles d'import.
82
+ from rundeck.v1.objects.config_management import ConfigManagementManager
83
+ from rundeck.v1.objects.executions import ExecutionManager
84
+ from rundeck.v1.objects.features import FeatureManager
85
+ from rundeck.v1.objects.jobs import JobManager
86
+ from rundeck.v1.objects.key_storage import StorageKeyManager
87
+ from rundeck.v1.objects.metrics import MetricsManager
88
+ from rundeck.v1.objects.plugins import PluginManager
89
+ from rundeck.v1.objects.projects import ProjectManager
90
+ from rundeck.v1.objects.scheduler import SchedulerManager
91
+ from rundeck.v1.objects.system import SystemManager
92
+ from rundeck.v1.objects.tokens import TokenManager
93
+ from rundeck.v1.objects.users import UserManager
94
+ from rundeck.v1.objects.webhooks import WebhookEventManager
95
+
96
+ self.projects = ProjectManager(self)
97
+ self.jobs = JobManager(self)
98
+ self.executions = ExecutionManager(self)
99
+ self.config_management = ConfigManagementManager(self)
100
+ self.metrics = MetricsManager(self)
101
+ self.plugins = PluginManager(self)
102
+ self.webhooks = WebhookEventManager(self)
103
+ self.key_storage = StorageKeyManager(self)
104
+ self.features = FeatureManager(self)
105
+ self.system = SystemManager(self)
106
+ self.scheduler = SchedulerManager(self)
107
+ self.tokens = TokenManager(self)
108
+ self.users = UserManager(self)
109
+
110
+ @property
111
+ def url(self) -> str:
112
+ """URL fournie par l'utilisateur."""
113
+ return self._base_url
114
+
115
+ @property
116
+ def api_url(self) -> str:
117
+ """URL de base de l'API."""
118
+ return self._api_url
119
+
120
+ @property
121
+ def api_version(self) -> str:
122
+ """Version de l'API utilisée."""
123
+ return self._api_version
124
+
125
+ @classmethod
126
+ def from_config(
127
+ cls,
128
+ config_section: str | None = None,
129
+ config_files: list[str] | None = None,
130
+ **kwargs: Any,
131
+ ) -> "Rundeck":
132
+ """
133
+ Construit un client Rundeck à partir des fichiers de configuration/env.
134
+ """
135
+ conf = RundeckConfig(
136
+ config_files=config_files,
137
+ config_section=config_section or "rundeck",
138
+ )
139
+ return cls(
140
+ url=conf.url,
141
+ token=conf.token,
142
+ username=conf.username,
143
+ password=conf.password,
144
+ api_version=conf.api_version,
145
+ timeout=conf.timeout,
146
+ ssl_verify=conf.ssl_verify,
147
+ session=kwargs.get("session"),
148
+ config_files=config_files,
149
+ )
150
+
151
+ def _authenticate_with_password(self) -> None:
152
+ """Authentification via j_security_check + cookie de session."""
153
+ login_url = urljoin(f"{self._base_url}/", "j_security_check")
154
+ try:
155
+ response = self.session.post(
156
+ login_url,
157
+ data={
158
+ "j_username": self.config.username,
159
+ "j_password": self.config.password,
160
+ },
161
+ allow_redirects=True,
162
+ timeout=self.timeout,
163
+ verify=self.ssl_verify,
164
+ headers={
165
+ "Accept": "application/json",
166
+ "User-Agent": self.headers.get("User-Agent", "python-rundeck"),
167
+ },
168
+ )
169
+ except requests.exceptions.Timeout as exc:
170
+ raise RundeckTimeoutError(
171
+ f"Timeout lors de l'authentification: {exc}"
172
+ ) from exc
173
+ except requests.exceptions.ConnectionError as exc:
174
+ raise RundeckConnectionError(
175
+ f"Erreur de connexion lors de l'authentification: {exc}"
176
+ ) from exc
177
+
178
+ urls = [response.url] + [r.url for r in response.history]
179
+ if any("/user/login" in (u or "") or "/user/error" in (u or "") for u in urls):
180
+ raise RundeckAuthenticationError(
181
+ "Authentification échouée (login/error)", None, response
182
+ )
183
+ if response.status_code >= 400:
184
+ raise RundeckAuthenticationError(
185
+ f"Authentification échouée (HTTP {response.status_code})",
186
+ None,
187
+ response,
188
+ )
189
+
190
+ cookies = self.session.cookies.get_dict()
191
+ if "JSESSIONID" not in cookies:
192
+ raise RundeckAuthenticationError(
193
+ "Cookie de session JSESSIONID manquant après authentification",
194
+ None,
195
+ response,
196
+ )
197
+
198
+ def _make_request(
199
+ self,
200
+ method: str,
201
+ url: str,
202
+ params: dict[str, Any] | None = None,
203
+ data: Any = None,
204
+ json: Any = None,
205
+ headers: dict[str, str] | None = None,
206
+ files: dict[str, Any] | None = None,
207
+ **kwargs: Any,
208
+ ) -> requests.Response:
209
+ if headers:
210
+ request_headers = self.session.headers.copy()
211
+ request_headers.update(headers)
212
+ else:
213
+ request_headers = None
214
+
215
+ try:
216
+ response = self.session.request(
217
+ method=method,
218
+ url=url,
219
+ params=params,
220
+ data=data,
221
+ json=json,
222
+ files=files,
223
+ headers=request_headers,
224
+ timeout=self.timeout,
225
+ verify=self.ssl_verify,
226
+ **kwargs,
227
+ )
228
+ raise_for_status(response)
229
+ return response
230
+ except requests.exceptions.Timeout as exc:
231
+ raise RundeckTimeoutError(f"Timeout lors de la requête: {exc}") from exc
232
+ except requests.exceptions.ConnectionError as exc:
233
+ raise RundeckConnectionError(f"Erreur de connexion: {exc}") from exc
234
+
235
+ def _request(
236
+ self,
237
+ method: str,
238
+ path: str,
239
+ params: dict[str, Any] | None = None,
240
+ data: Any = None,
241
+ json: Any = None,
242
+ headers: dict[str, str] | None = None,
243
+ files: dict[str, Any] | None = None,
244
+ raw: bool = False,
245
+ **kwargs: Any,
246
+ ) -> Any:
247
+ """
248
+ Effectue une requête sur le chemin donné et retourne le contenu déjà parsé.
249
+ """
250
+ url = path
251
+ if not path.startswith("http"):
252
+ url = urljoin(f"{self.api_url}/", path.lstrip("/"))
253
+
254
+ response = self._make_request(
255
+ method=method,
256
+ url=url,
257
+ params=params,
258
+ data=data,
259
+ json=json,
260
+ headers=headers,
261
+ files=files,
262
+ **kwargs,
263
+ )
264
+
265
+ if raw:
266
+ return response
267
+
268
+ if method == HTTP_DELETE:
269
+ return None
270
+ if response.text:
271
+ try:
272
+ return response.json()
273
+ except ValueError:
274
+ return response.text
275
+ return {}
276
+
277
+ def http_get(
278
+ self,
279
+ url: str,
280
+ params: dict[str, Any] | None = None,
281
+ raw: bool = False,
282
+ **kwargs: Any,
283
+ ) -> Any:
284
+ return self._request(HTTP_GET, url, params=params, raw=raw, **kwargs)
285
+
286
+ def http_list(
287
+ self,
288
+ url: str,
289
+ params: dict[str, Any] | None = None,
290
+ **kwargs: Any,
291
+ ) -> list[Any]:
292
+ result = self.http_get(url, params=params, **kwargs)
293
+
294
+ if isinstance(result, list):
295
+ return result
296
+ if isinstance(result, dict):
297
+ for key in ["items", "data", "results", "executions", "jobs", "projects"]:
298
+ if key in result and isinstance(result[key], list):
299
+ return result[key]
300
+ return [result] if result else []
301
+ return []
302
+
303
+ def http_post(
304
+ self,
305
+ url: str,
306
+ data: dict[str, Any] | None = None,
307
+ json: dict[str, Any] | None = None,
308
+ files: dict[str, Any] | None = None,
309
+ raw: bool = False,
310
+ **kwargs: Any,
311
+ ) -> Any:
312
+ return self._request(
313
+ HTTP_POST, url, data=data, json=json, files=files, raw=raw, **kwargs
314
+ )
315
+
316
+ def http_put(
317
+ self,
318
+ url: str,
319
+ data: dict[str, Any] | None = None,
320
+ json: dict[str, Any] | None = None,
321
+ files: dict[str, Any] | None = None,
322
+ raw: bool = False,
323
+ **kwargs: Any,
324
+ ) -> Any:
325
+ return self._request(
326
+ HTTP_PUT, url, data=data, json=json, files=files, raw=raw, **kwargs
327
+ )
328
+
329
+ def http_delete(
330
+ self,
331
+ url: str,
332
+ raw: bool = False,
333
+ **kwargs: Any,
334
+ ) -> None:
335
+ self._request(HTTP_DELETE, url, raw=raw, **kwargs)
336
+
337
+ def __enter__(self) -> "Rundeck":
338
+ return self
339
+
340
+ def __exit__(self, *args: Any) -> None:
341
+ self.session.close()