python-sysaid 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.
sysaid/exceptions.py ADDED
@@ -0,0 +1,95 @@
1
+ """Exceptions raised by the SysAid client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from http.client import responses
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+
11
+ class SysAidError(Exception):
12
+ """Base class for every error raised by this package."""
13
+
14
+
15
+ class AuthenticationError(SysAidError):
16
+ """Login failed, or credentials are missing."""
17
+
18
+
19
+ class UnverifiedFeatureError(SysAidError):
20
+ """The feature is disabled until it has been verified against a live server."""
21
+
22
+
23
+ class SysAidHTTPError(SysAidError):
24
+ """The server answered with a non-2xx status."""
25
+
26
+ def __init__(
27
+ self, status_code: int, message: str, response: requests.Response | None = None
28
+ ) -> None:
29
+ super().__init__(f"HTTP {status_code}: {message}")
30
+ self.status_code = status_code
31
+ self.message = message
32
+ self.response = response
33
+
34
+
35
+ class BadRequestError(SysAidHTTPError):
36
+ """HTTP 400."""
37
+
38
+
39
+ class RelationError(BadRequestError):
40
+ """CI relations could not be created; ``failures`` lists each failing item."""
41
+
42
+ @property
43
+ def failures(self) -> list[str]:
44
+ """The per-item messages from the server's CSV error text."""
45
+ return [part.strip() for part in self.message.split(",") if part.strip()]
46
+
47
+
48
+ class UnauthorizedError(SysAidHTTPError):
49
+ """HTTP 401."""
50
+
51
+
52
+ class ForbiddenError(SysAidHTTPError):
53
+ """HTTP 403."""
54
+
55
+
56
+ class NotFoundError(SysAidHTTPError):
57
+ """HTTP 404."""
58
+
59
+
60
+ class ServerError(SysAidHTTPError):
61
+ """HTTP 5xx."""
62
+
63
+
64
+ _BY_STATUS: dict[int, type[SysAidHTTPError]] = {
65
+ 400: BadRequestError,
66
+ 401: UnauthorizedError,
67
+ 403: ForbiddenError,
68
+ 404: NotFoundError,
69
+ }
70
+
71
+
72
+ def _message_from(response: requests.Response) -> str:
73
+ """Return ``message`` from a ``{"status", "message"}`` body, else the raw text.
74
+
75
+ Unrouted or filtered requests get the servlet container's HTML error page; for
76
+ those the reason phrase is used instead of the markup.
77
+ """
78
+ try:
79
+ body: Any = response.json()
80
+ except ValueError:
81
+ if "html" in response.headers.get("Content-Type", ""):
82
+ return response.reason or responses.get(response.status_code, "")
83
+ return response.text or response.reason or ""
84
+ if isinstance(body, dict) and body.get("message") is not None:
85
+ return str(body["message"])
86
+ return response.text
87
+
88
+
89
+ def error_from_response(response: requests.Response) -> SysAidHTTPError:
90
+ """Build the typed exception matching a non-2xx response."""
91
+ status = response.status_code
92
+ cls = _BY_STATUS.get(status)
93
+ if cls is None:
94
+ cls = ServerError if status >= 500 else SysAidHTTPError
95
+ return cls(status, _message_from(response), response)
sysaid/models.py ADDED
@@ -0,0 +1,76 @@
1
+ """Light wrappers around the ``{id, info[]}`` objects returned by SysAid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ def _as_bool(value: Any) -> bool | None:
11
+ if isinstance(value, bool):
12
+ return value
13
+ if isinstance(value, str) and value.lower() in ("true", "false"):
14
+ return value.lower() == "true"
15
+ return None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Field:
20
+ """One ``info`` entry. Metadata is only present on form and template calls."""
21
+
22
+ key: str
23
+ value: Any = None
24
+ key_caption: str | None = None
25
+ value_caption: Any = None
26
+ mandatory: bool | None = None
27
+ editable: bool | None = None
28
+ type: str | None = None
29
+ default_value: Any = None
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: Mapping[str, Any]) -> Field:
33
+ """Build from one ``info`` entry (accepts both caption spellings)."""
34
+ # Assets use snake_case captions, everything else camelCase.
35
+ return cls(
36
+ key=str(data["key"]),
37
+ value=data.get("value"),
38
+ key_caption=data.get("keyCaption", data.get("key_caption")),
39
+ value_caption=data.get("valueCaption", data.get("value_caption")),
40
+ mandatory=_as_bool(data.get("mandatory")),
41
+ editable=_as_bool(data.get("editable")),
42
+ type=data.get("type"),
43
+ default_value=data.get("defaultValue"),
44
+ )
45
+
46
+
47
+ class Record(Mapping[str, Any]):
48
+ """An entity with an ``id`` and an ``info`` array, read like a mapping of key -> value."""
49
+
50
+ def __init__(self, raw: Mapping[str, Any]) -> None:
51
+ self.raw: dict[str, Any] = dict(raw)
52
+ self.fields: dict[str, Field] = {
53
+ field.key: field for field in map(Field.from_dict, self.raw.get("info") or [])
54
+ }
55
+
56
+ @property
57
+ def id(self) -> str | None:
58
+ """The record id as a string, if present."""
59
+ value = self.raw.get("id")
60
+ return None if value is None else str(value)
61
+
62
+ def __getitem__(self, key: str) -> Any:
63
+ return self.fields[key].value
64
+
65
+ def __iter__(self) -> Iterator[str]:
66
+ return iter(self.fields)
67
+
68
+ def __len__(self) -> int:
69
+ return len(self.fields)
70
+
71
+ def caption(self, key: str) -> Any:
72
+ """Display value (``valueCaption``) of a field."""
73
+ return self.fields[key].value_caption
74
+
75
+ def __repr__(self) -> str:
76
+ return f"Record(id={self.id!r}, fields={list(self.fields)!r})"
sysaid/oauth.py ADDED
@@ -0,0 +1,85 @@
1
+ """OAuth 1.0 helpers (optional extra: ``pip install python-sysaid[oauth]``).
2
+
3
+ Three-legged flow: :func:`request_token` -> send the user to :func:`authorize_url` ->
4
+ :func:`access_token` with the ``oauth_verifier`` from the callback. Then build a client
5
+ with :meth:`sysaid.SysAid.from_oauth`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import urlencode
12
+
13
+ import requests
14
+
15
+ from ._unverified import unverified
16
+ from .client import api_url_for
17
+ from .exceptions import error_from_response
18
+
19
+
20
+ def oauth1(client_key: str, client_secret: str, **kwargs: Any) -> Any:
21
+ """Build a ``requests_oauthlib.OAuth1`` auth object (HMAC-SHA1)."""
22
+ try:
23
+ from requests_oauthlib import OAuth1
24
+ except ImportError as exc:
25
+ raise ImportError(
26
+ "OAuth support needs requests-oauthlib: pip install 'python-sysaid[oauth]'"
27
+ ) from exc
28
+ return OAuth1(client_key, client_secret=client_secret, **kwargs)
29
+
30
+
31
+ def _post(
32
+ base_url: str, path: str, auth: Any, verify: bool | str, timeout: float | None
33
+ ) -> dict[str, Any]:
34
+ response = requests.post(
35
+ api_url_for(base_url) + path, auth=auth, verify=verify, timeout=timeout
36
+ )
37
+ if not response.ok:
38
+ raise error_from_response(response)
39
+ token: dict[str, Any] = response.json()
40
+ return token
41
+
42
+
43
+ @unverified
44
+ def request_token(
45
+ base_url: str,
46
+ consumer_key: str,
47
+ callback_url: str,
48
+ consumer_secret: str = "",
49
+ *,
50
+ verify: bool | str = True,
51
+ timeout: float | None = 30,
52
+ ) -> dict[str, Any]:
53
+ """Step 1. Returns ``oauth_token``, ``oauth_token_secret`` and ``oauth_callback_confirmed``."""
54
+ auth = oauth1(consumer_key, consumer_secret, callback_uri=callback_url)
55
+ return _post(base_url, "/oauth/request_token", auth, verify, timeout)
56
+
57
+
58
+ @unverified
59
+ def authorize_url(base_url: str, request_token: str) -> str:
60
+ """Step 2. The URL to send the user's browser to."""
61
+ query = urlencode({"oauth_token": request_token})
62
+ return f"{api_url_for(base_url)}/oauth/authorize?{query}"
63
+
64
+
65
+ @unverified
66
+ def access_token(
67
+ base_url: str,
68
+ consumer_key: str,
69
+ request_token: str,
70
+ request_token_secret: str,
71
+ verifier: str,
72
+ consumer_secret: str = "",
73
+ *,
74
+ verify: bool | str = True,
75
+ timeout: float | None = 30,
76
+ ) -> dict[str, Any]:
77
+ """Step 3. Returns the access ``oauth_token`` and ``oauth_token_secret``."""
78
+ auth = oauth1(
79
+ consumer_key,
80
+ consumer_secret,
81
+ resource_owner_key=request_token,
82
+ resource_owner_secret=request_token_secret,
83
+ verifier=verifier,
84
+ )
85
+ return _post(base_url, "/oauth/access_token", auth, verify, timeout)
sysaid/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,70 @@
1
+ """Shared plumbing for the resource classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping, Sequence
6
+ from os import PathLike
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from ..models import Record
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import SysAid
14
+
15
+ DEFAULT_PAGE_SIZE = 100
16
+
17
+ # Resource classes define methods named ``list``/``iter``, which would shadow the
18
+ # builtins in annotations inside the class body; these aliases avoid that.
19
+ RecordList = list[Record]
20
+ JSONDict = dict[str, Any]
21
+ JSONList = list[dict[str, Any]]
22
+
23
+
24
+ def query(
25
+ *,
26
+ view: str | None = None,
27
+ fields: Sequence[str] | None = None,
28
+ sort: str | None = None,
29
+ direction: str | None = None,
30
+ **extra: Any,
31
+ ) -> dict[str, Any]:
32
+ """Common list parameters (``dir`` is exposed as ``direction``) plus extras."""
33
+ return {"view": view, "fields": fields, "sort": sort, "dir": direction, **extra}
34
+
35
+
36
+ def read_upload(file: bytes | str | PathLike[str], filename: str | None) -> tuple[str, bytes]:
37
+ """Resolve an upload given as bytes or a path into ``(filename, content)``."""
38
+ if isinstance(file, bytes):
39
+ return filename or "file", file
40
+ path = Path(file)
41
+ return filename or path.name, path.read_bytes()
42
+
43
+
44
+ class Resource:
45
+ """Base class: holds the client and the shared GET/list/paginate helpers."""
46
+
47
+ def __init__(self, client: SysAid) -> None:
48
+ self._client = client
49
+
50
+ def _get(self, path: str, params: Mapping[str, Any] | None = None) -> Record:
51
+ return Record(self._client.request("GET", path, params=params))
52
+
53
+ def _list(self, path: str, params: Mapping[str, Any] | None = None) -> RecordList:
54
+ return [Record(item) for item in self._client.request("GET", path, params=params)]
55
+
56
+ def _iter(
57
+ self,
58
+ path: str,
59
+ params: Mapping[str, Any] | None = None,
60
+ *,
61
+ page_size: int = DEFAULT_PAGE_SIZE,
62
+ offset: int = 0,
63
+ ) -> Iterator[Record]:
64
+ """Yield every record, fetching pages until one is shorter than ``page_size``."""
65
+ while True:
66
+ page = self._list(path, {**(params or {}), "offset": offset, "limit": page_size})
67
+ yield from page
68
+ if len(page) < page_size:
69
+ return
70
+ offset += page_size
@@ -0,0 +1,127 @@
1
+ """Action items: ``/action_item``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Sequence
6
+ from typing import Any
7
+
8
+ from .._params import quote_segment
9
+ from .._unverified import unverified
10
+ from ..models import Record
11
+ from ._base import DEFAULT_PAGE_SIZE, RecordList, Resource, query
12
+
13
+
14
+ def _filter_params(
15
+ type: str | Sequence[str] | None,
16
+ ids: Sequence[int | str] | None,
17
+ archive: bool | None,
18
+ static_filter_id: str | None,
19
+ text: str | None,
20
+ filters: dict[str, Any],
21
+ ) -> dict[str, Any]:
22
+ params: dict[str, Any] = {
23
+ "type": type,
24
+ "ids": ids,
25
+ "staticFilterId": static_filter_id,
26
+ "query": text,
27
+ }
28
+ if archive is not None:
29
+ params["archive"] = 1 if archive else 0
30
+ return {**params, **filters}
31
+
32
+
33
+ class ActionItems(Resource):
34
+ """Action items attached to service requests."""
35
+
36
+ @unverified
37
+ def list(
38
+ self,
39
+ *,
40
+ type: str | Sequence[str] | None = None,
41
+ ids: Sequence[int | str] | None = None,
42
+ archive: bool | None = None,
43
+ static_filter_id: str | None = None,
44
+ text: str | None = None,
45
+ view: str | None = None,
46
+ fields: Sequence[str] | None = None,
47
+ offset: int | None = None,
48
+ limit: int | None = None,
49
+ sort: str | None = None,
50
+ direction: str | None = None,
51
+ **filters: Any,
52
+ ) -> RecordList:
53
+ """One page of action items. ``ids`` are the *SR* ids whose items to return."""
54
+ params = query(
55
+ view=view,
56
+ fields=fields,
57
+ sort=sort,
58
+ direction=direction,
59
+ offset=offset,
60
+ limit=limit,
61
+ **_filter_params(type, ids, archive, static_filter_id, text, filters),
62
+ )
63
+ return self._list("/action_item", params)
64
+
65
+ @unverified
66
+ def iter(
67
+ self,
68
+ *,
69
+ type: str | Sequence[str] | None = None,
70
+ ids: Sequence[int | str] | None = None,
71
+ archive: bool | None = None,
72
+ static_filter_id: str | None = None,
73
+ text: str | None = None,
74
+ view: str | None = None,
75
+ fields: Sequence[str] | None = None,
76
+ sort: str | None = None,
77
+ direction: str | None = None,
78
+ page_size: int = DEFAULT_PAGE_SIZE,
79
+ **filters: Any,
80
+ ) -> Iterator[Record]:
81
+ """Every matching action item, fetching pages transparently."""
82
+ params = query(
83
+ view=view,
84
+ fields=fields,
85
+ sort=sort,
86
+ direction=direction,
87
+ **_filter_params(type, ids, archive, static_filter_id, text, filters),
88
+ )
89
+ return self._iter("/action_item", params, page_size=page_size)
90
+
91
+ @unverified
92
+ def count(
93
+ self,
94
+ *,
95
+ type: str | Sequence[str] | None = None,
96
+ ids: Sequence[int | str] | None = None,
97
+ archive: bool | None = None,
98
+ static_filter_id: str | None = None,
99
+ text: str | None = None,
100
+ **filters: Any,
101
+ ) -> int:
102
+ """Number of action items matching the filters."""
103
+ params = _filter_params(type, ids, archive, static_filter_id, text, filters)
104
+ return int(self._client.request("GET", "/action_item/count", params=params)["count"])
105
+
106
+ @unverified
107
+ def approve(self, action_item_id: int | str) -> None:
108
+ """Approve the action item."""
109
+ self._change_state(action_item_id, "approve")
110
+
111
+ @unverified
112
+ def reject(self, action_item_id: int | str) -> None:
113
+ """Reject the action item."""
114
+ self._change_state(action_item_id, "reject")
115
+
116
+ @unverified
117
+ def complete(self, action_item_id: int | str) -> None:
118
+ """Mark the action item as complete."""
119
+ self._change_state(action_item_id, "complete")
120
+
121
+ @unverified
122
+ def reopen(self, action_item_id: int | str) -> None:
123
+ """Reopen the action item."""
124
+ self._change_state(action_item_id, "reopen")
125
+
126
+ def _change_state(self, action_item_id: int | str, action: str) -> None:
127
+ self._client.request("PUT", f"/action_item/{quote_segment(action_item_id)}/{action}")
@@ -0,0 +1,63 @@
1
+ """Add-ons: ``/addons``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from .._params import quote_segment
9
+ from .._unverified import unverified
10
+ from ._base import JSONDict, JSONList, Resource
11
+
12
+
13
+ def _payload(name: str, active: bool | None, params: Mapping[str, Any] | None) -> JSONDict:
14
+ body: JSONDict = {"name": name}
15
+ if active is not None:
16
+ body["active"] = active
17
+ if params is not None:
18
+ body["params"] = [{"name": key, "value": value} for key, value in params.items()]
19
+ return body
20
+
21
+
22
+ class Addons(Resource):
23
+ """SysAid add-ons and their parameters."""
24
+
25
+ @unverified
26
+ def list(self) -> JSONList:
27
+ """All add-ons (``params`` is always ``None`` in this list)."""
28
+ result: JSONList = self._client.request("GET", "/addons")
29
+ return result
30
+
31
+ @unverified
32
+ def get(self, name: str) -> JSONDict:
33
+ """The add-on with its ``params``."""
34
+ result: JSONDict = self._client.request("GET", f"/addons/{quote_segment(name)}")
35
+ return result
36
+
37
+ @unverified
38
+ def update(
39
+ self, name: str, *, active: bool | None = None, params: Mapping[str, Any] | None = None
40
+ ) -> Any:
41
+ """Set ``active`` and/or parameter values (``{param name: value}``).
42
+
43
+ Only these are updated server-side. The server answers with a message.
44
+ """
45
+ return self._client.request(
46
+ "PUT", f"/addons/{quote_segment(name)}", json=_payload(name, active, params)
47
+ )
48
+
49
+ @unverified
50
+ def test_connection(
51
+ self, name: str, *, active: bool | None = None, params: Mapping[str, Any] | None = None
52
+ ) -> Any:
53
+ """Same payload as :meth:`update`, but only tests; nothing is saved."""
54
+ return self._client.request(
55
+ "PUT",
56
+ f"/addons/{quote_segment(name)}/testConnection",
57
+ json=_payload(name, active, params),
58
+ )
59
+
60
+ @unverified
61
+ def refresh(self) -> Any:
62
+ """Refresh the add-ons list immediately."""
63
+ return self._client.request("GET", "/addons/refresh")
@@ -0,0 +1,61 @@
1
+ """Assets: ``/asset``. Ids look like ``497db453:147bee7ec09:-7ff2`` and are URL-encoded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Sequence
6
+
7
+ from .._params import quote_segment
8
+ from .._unverified import unverified
9
+ from ..models import Record
10
+ from ._base import DEFAULT_PAGE_SIZE, RecordList, Resource, query
11
+
12
+
13
+ class Assets(Resource):
14
+ """Read-only access to assets."""
15
+
16
+ @unverified
17
+ def list(
18
+ self,
19
+ *,
20
+ type: str | None = None,
21
+ view: str | None = None,
22
+ fields: Sequence[str] | None = None,
23
+ offset: int | None = None,
24
+ limit: int | None = None,
25
+ ) -> RecordList:
26
+ """``type`` is listed in the Help index only; it is passed through when given."""
27
+ params = query(view=view, fields=fields, type=type, offset=offset, limit=limit)
28
+ return self._list("/asset", params)
29
+
30
+ @unverified
31
+ def iter(
32
+ self,
33
+ *,
34
+ type: str | None = None,
35
+ view: str | None = None,
36
+ fields: Sequence[str] | None = None,
37
+ page_size: int = DEFAULT_PAGE_SIZE,
38
+ ) -> Iterator[Record]:
39
+ """Every asset, fetching pages transparently."""
40
+ return self._iter("/asset", query(view=view, fields=fields, type=type), page_size=page_size)
41
+
42
+ @unverified
43
+ def get(
44
+ self, asset_id: str, *, view: str | None = None, fields: Sequence[str] | None = None
45
+ ) -> Record:
46
+ """One asset; ``view`` is an Asset Form view."""
47
+ return self._get(f"/asset/{quote_segment(asset_id)}", query(view=view, fields=fields))
48
+
49
+ @unverified
50
+ def search(
51
+ self,
52
+ text: str,
53
+ *,
54
+ view: str | None = None,
55
+ fields: Sequence[str] | None = None,
56
+ offset: int | None = None,
57
+ limit: int | None = None,
58
+ ) -> RecordList:
59
+ """Search assets by free text."""
60
+ params = query(view=view, fields=fields, query=text, offset=offset, limit=limit)
61
+ return self._list("/asset/search", params)