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.
- python_sysaid-0.1.0.dist-info/METADATA +205 -0
- python_sysaid-0.1.0.dist-info/RECORD +26 -0
- python_sysaid-0.1.0.dist-info/WHEEL +4 -0
- python_sysaid-0.1.0.dist-info/licenses/LICENSE +21 -0
- sysaid/__init__.py +37 -0
- sysaid/_params.py +80 -0
- sysaid/_unverified.py +33 -0
- sysaid/auth.py +62 -0
- sysaid/client.py +160 -0
- sysaid/exceptions.py +95 -0
- sysaid/models.py +76 -0
- sysaid/oauth.py +85 -0
- sysaid/py.typed +0 -0
- sysaid/resources/__init__.py +0 -0
- sysaid/resources/_base.py +70 -0
- sysaid/resources/action_items.py +127 -0
- sysaid/resources/addons.py +63 -0
- sysaid/resources/assets.py +61 -0
- sysaid/resources/cis.py +134 -0
- sysaid/resources/filters.py +44 -0
- sysaid/resources/lists.py +56 -0
- sysaid/resources/password_services.py +74 -0
- sysaid/resources/reports.py +30 -0
- sysaid/resources/resource_bundle.py +18 -0
- sysaid/resources/service_requests.py +260 -0
- sysaid/resources/users.py +108 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-sysaid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python wrapper for the SysAid REST API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: api,itsm,rest,sysaid
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: requests>=2.31
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
22
|
+
Requires-Dist: requests-oauthlib>=1.3; extra == 'dev'
|
|
23
|
+
Requires-Dist: responses; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine; extra == 'dev'
|
|
26
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
27
|
+
Requires-Dist: types-requests-oauthlib; extra == 'dev'
|
|
28
|
+
Provides-Extra: oauth
|
|
29
|
+
Requires-Dist: requests-oauthlib>=1.3; extra == 'oauth'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# python-sysaid
|
|
33
|
+
|
|
34
|
+
Python wrapper for the SysAid REST API (`/api/v1`, SysAid 15.4+).
|
|
35
|
+
|
|
36
|
+
> Status: alpha, under development. Requests and paths come from the SysAid
|
|
37
|
+
> documentation and have not been validated against a live server yet.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install python-sysaid
|
|
43
|
+
pip install "python-sysaid[oauth]" # optional: OAuth 1.0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.10+.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sysaid import SysAid
|
|
52
|
+
|
|
53
|
+
with SysAid("https://sysaid.example.com", username="sysaid", password="...") as client:
|
|
54
|
+
sr = client.service_requests.get(273, fields=["title", "status"])
|
|
55
|
+
sr["title"] # raw value
|
|
56
|
+
sr.caption("status") # display value
|
|
57
|
+
|
|
58
|
+
for sr in client.service_requests.iter(type="incident", status=[4, 5]):
|
|
59
|
+
print(sr.id, sr["title"])
|
|
60
|
+
|
|
61
|
+
client.service_requests.update(273, status=2, responsibility=66)
|
|
62
|
+
client.service_requests.close(273, solution="restarted")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The client logs in on first use (`POST /login`, the `JSESSIONID` cookie is kept by the
|
|
66
|
+
session). The account must be an administrator with mobile-app permission. Pass
|
|
67
|
+
`account_id=` if your installation needs one, and `verify=`/`timeout=`/`session=` to
|
|
68
|
+
control the underlying `requests` session.
|
|
69
|
+
|
|
70
|
+
## Records
|
|
71
|
+
|
|
72
|
+
List and get calls return `Record` objects, which behave like a read-only mapping of
|
|
73
|
+
field key to raw value:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
record["status"] # raw value (an id, ms timestamp or text)
|
|
77
|
+
record.caption("status") # display value (valueCaption)
|
|
78
|
+
record.fields["status"] # Field: key_caption, mandatory, editable, type, ...
|
|
79
|
+
record.raw # the original dict (canUpdate, name, group, ...)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Pagination
|
|
83
|
+
|
|
84
|
+
`list()` returns one page (`limit`/`offset`); `iter()` yields every record and stops at the
|
|
85
|
+
first page shorter than `page_size` (default 100):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
client.service_requests.list(limit=50, offset=100)
|
|
89
|
+
client.service_requests.iter(page_size=200)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Filters and parameters
|
|
93
|
+
|
|
94
|
+
Filter ids come from `client.filters`. Pass them as keyword arguments:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
client.service_requests.list(status=[4, 5], request_user=235) # status=4,5&request_user=235
|
|
98
|
+
client.service_requests.list(archive=True) # archive=1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use `view=` and `fields=` to choose the returned fields, and `sort=`/`direction=`.
|
|
102
|
+
|
|
103
|
+
## Dates
|
|
104
|
+
|
|
105
|
+
Datetimes are sent as milliseconds since the epoch in UTC (naive datetimes are taken as
|
|
106
|
+
UTC). A `(from, to)` tuple is a range, with `None` for an open end:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from datetime import datetime, timezone
|
|
110
|
+
|
|
111
|
+
since = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
112
|
+
client.service_requests.list(due_date=(since, None)) # due_date=<ms>,0
|
|
113
|
+
client.service_requests.update(273, due_date=datetime.now(timezone.utc))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Writing
|
|
117
|
+
|
|
118
|
+
Write calls take field values as keywords. For field ids that clash with a method's own
|
|
119
|
+
keywords (the SR field `type`), pass a mapping as the first argument:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from sysaid.resources.service_requests import make_note, problem_type
|
|
123
|
+
|
|
124
|
+
client.service_requests.create(
|
|
125
|
+
{"type": 3},
|
|
126
|
+
type="incident", # the SR type query parameter
|
|
127
|
+
template=39,
|
|
128
|
+
title="Printer down",
|
|
129
|
+
problem_type=problem_type("UserWorkstation", "PC", "Password"),
|
|
130
|
+
notes=[make_note("sysaid", "Created from the API")],
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`client.service_requests.template(type="incident")` shows the mandatory fields first.
|
|
135
|
+
|
|
136
|
+
Numbers, booleans and datetimes are sent as strings, which is the only form the server
|
|
137
|
+
accepts for field values. `add_activity` takes the numeric id of the user.
|
|
138
|
+
|
|
139
|
+
## Resources
|
|
140
|
+
|
|
141
|
+
| Attribute | Covers |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `client.users` | list, iter, get, search, photo get/upload, permissions |
|
|
144
|
+
| `client.filters` / `client.lists` | filter definitions, dropdown id/caption pairs |
|
|
145
|
+
| `client.service_requests` | list, iter, get, search, count, template, create, update, close, links, attachments, activities, `send_message` |
|
|
146
|
+
| `client.resource_bundle` | translate |
|
|
147
|
+
|
|
148
|
+
### Disabled features
|
|
149
|
+
|
|
150
|
+
These are implemented from the REST guide but have not been verified against a live server
|
|
151
|
+
yet, so calling them raises `UnverifiedFeatureError` before any request is made:
|
|
152
|
+
|
|
153
|
+
| Attribute | Disabled calls |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `client.service_requests` | delete |
|
|
156
|
+
| `client.action_items` | list, iter, count, approve, reject, complete, reopen |
|
|
157
|
+
| `client.assets` | list, iter, get, search |
|
|
158
|
+
| `client.cis` | list, iter, update, types, view_fields, relation types, relations |
|
|
159
|
+
| `client.addons` | list, get, update, test_connection, refresh |
|
|
160
|
+
| `client.password_services` | domains, permissions, questions, unlock, reset, update_password |
|
|
161
|
+
| `client.reports` | operators, run_preview |
|
|
162
|
+
| OAuth 1.0 | `SysAid.from_oauth` and the `sysaid.oauth` helpers |
|
|
163
|
+
|
|
164
|
+
## Errors
|
|
165
|
+
|
|
166
|
+
Every error derives from `SysAidError`. Non-2xx answers raise a subclass of
|
|
167
|
+
`SysAidHTTPError` (`BadRequestError`, `UnauthorizedError`, `ForbiddenError`,
|
|
168
|
+
`NotFoundError`, `ServerError`) carrying `status_code`, `message` and `response`.
|
|
169
|
+
Failed logins raise `AuthenticationError`. Failed CI relation creation raises
|
|
170
|
+
`RelationError` with the per-item `failures`.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from sysaid import NotFoundError
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
client.service_requests.get(999999)
|
|
177
|
+
except NotFoundError as exc:
|
|
178
|
+
print(exc.status_code, exc.message)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## OAuth 1.0
|
|
182
|
+
|
|
183
|
+
Disabled until verified (see [Disabled features](#disabled-features)). The intended flow
|
|
184
|
+
needs `python-sysaid[oauth]` and a consumer key issued by SysAid:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from sysaid import SysAid, oauth
|
|
188
|
+
|
|
189
|
+
token = oauth.request_token(url, consumer_key, "https://app/callback")
|
|
190
|
+
print(oauth.authorize_url(url, token["oauth_token"]))
|
|
191
|
+
# ...the user authorizes; SysAid redirects with oauth_verifier...
|
|
192
|
+
access = oauth.access_token(
|
|
193
|
+
url, consumer_key, token["oauth_token"], token["oauth_token_secret"], verifier
|
|
194
|
+
)
|
|
195
|
+
client = SysAid.from_oauth(url, consumer_key, access["oauth_token"], access["oauth_token_secret"])
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). The endpoint reference is in
|
|
201
|
+
[ANOTATIONS.md](ANOTATIONS.md) and the roadmap in [PLAN.md](PLAN.md).
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
sysaid/__init__.py,sha256=ErK_rfRygwKRSPXMzuaQ8XGC_MWiiNSVVnOGtSnckUA,723
|
|
2
|
+
sysaid/_params.py,sha256=HyIp23Crj5ECFnprui0F2imV9sABmasFEl5OUd5Sy_U,2797
|
|
3
|
+
sysaid/_unverified.py,sha256=wmxcbCg30ZKG-busTFBMVo4ijV_tSc1n6TXJLm-U1bE,968
|
|
4
|
+
sysaid/auth.py,sha256=dzvMgmDn1WrKkNV18wY7ZqhWenFJMXWcJSsTwPbFtQo,2061
|
|
5
|
+
sysaid/client.py,sha256=UiUMo0DwdB3z0MlleGyt553qbP55A2yOmX5tTkpz-8E,5078
|
|
6
|
+
sysaid/exceptions.py,sha256=nxI8csNKcURxe9ZNRDWfrhu-EHUT_WEQ_5xtWh7mOv8,2668
|
|
7
|
+
sysaid/models.py,sha256=2c5qqE-cQCqr-ArydjKwY8ulVl1ZUV5j_9PHWiGZi4w,2490
|
|
8
|
+
sysaid/oauth.py,sha256=WmbK4vilbt1QMnZgv94f-zsQxjz9BJTd_47WymisS-w,2650
|
|
9
|
+
sysaid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
sysaid/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
sysaid/resources/_base.py,sha256=dfEu5iBzeuYj5pvIhC7fCzBmCNJerJVLFN4Fw5AZ5wI,2304
|
|
12
|
+
sysaid/resources/action_items.py,sha256=tkVa3rNRGpZUD9ZJQgcQHNOob6KV-iVoXBUvr4gWec0,4097
|
|
13
|
+
sysaid/resources/addons.py,sha256=L1EWLAeRCtmbvkIhYBif2XpcI12Yx3MKI-ISq1P9cRQ,2083
|
|
14
|
+
sysaid/resources/assets.py,sha256=Yeb5lBo3-ntKD53IPiFMnP394O0X3shjJcG3eAkyfYk,2002
|
|
15
|
+
sysaid/resources/cis.py,sha256=a8Akeha_AEt-BvAReu1wzHROQG65pq0hMvLEDWWs0s4,4626
|
|
16
|
+
sysaid/resources/filters.py,sha256=R1ifN9xHt_KhKSo_d7hRt5P_6xPcdyn5GTe9jEqOjIw,1258
|
|
17
|
+
sysaid/resources/lists.py,sha256=65-c8_0JSxtllMQaf09SfDeH8-I9f_JxiWbO3V1zLlM,1624
|
|
18
|
+
sysaid/resources/password_services.py,sha256=F-Gg_MoemF8DS0Si1GpMCexDZtPlA9YxWzLbBQ3QRbU,2868
|
|
19
|
+
sysaid/resources/reports.py,sha256=wlockOuPc71dDcjoZqolINxwSofftVCjAlNJLdwtYV8,1013
|
|
20
|
+
sysaid/resources/resource_bundle.py,sha256=IjnJJPhxobsZk0Vs-WYkjkG49I1Fv6zNbTwyrnzJ9yE,667
|
|
21
|
+
sysaid/resources/service_requests.py,sha256=uFX9h1WMgX6wWiyQnfuCKjn5EEFfH2T-9yCZdnaC-Ws,9842
|
|
22
|
+
sysaid/resources/users.py,sha256=wq_1CX5LlbNnLio9FCj1H1OxlsDGAoXY8i1k0hNmRtw,3756
|
|
23
|
+
python_sysaid-0.1.0.dist-info/METADATA,sha256=ky-d8skM-f7mlCAo_KLQx-k9d94X-rMPrxYBIA62yRA,7106
|
|
24
|
+
python_sysaid-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
25
|
+
python_sysaid-0.1.0.dist-info/licenses/LICENSE,sha256=0oGaM8V0v4y5Af4LIP6awcbtI_Ub_t-wsnfYfsFXhXs,1070
|
|
26
|
+
python_sysaid-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruno Martins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sysaid/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Python wrapper for the SysAid REST API."""
|
|
2
|
+
|
|
3
|
+
from .auth import LoginResult
|
|
4
|
+
from .client import SysAid
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ForbiddenError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RelationError,
|
|
11
|
+
ServerError,
|
|
12
|
+
SysAidError,
|
|
13
|
+
SysAidHTTPError,
|
|
14
|
+
UnauthorizedError,
|
|
15
|
+
UnverifiedFeatureError,
|
|
16
|
+
)
|
|
17
|
+
from .models import Field, Record
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"BadRequestError",
|
|
24
|
+
"Field",
|
|
25
|
+
"ForbiddenError",
|
|
26
|
+
"LoginResult",
|
|
27
|
+
"NotFoundError",
|
|
28
|
+
"Record",
|
|
29
|
+
"RelationError",
|
|
30
|
+
"ServerError",
|
|
31
|
+
"SysAid",
|
|
32
|
+
"SysAidError",
|
|
33
|
+
"SysAidHTTPError",
|
|
34
|
+
"UnauthorizedError",
|
|
35
|
+
"UnverifiedFeatureError",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
sysaid/_params.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Encoding of Python values into SysAid query parameters and JSON bodies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_ms(value: datetime) -> int:
|
|
12
|
+
"""Milliseconds since the epoch, UTC. Naive datetimes are taken as UTC."""
|
|
13
|
+
if value.tzinfo is None:
|
|
14
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
15
|
+
return round(value.timestamp() * 1000)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_date_range(value: tuple[Any, ...]) -> bool:
|
|
19
|
+
return (
|
|
20
|
+
len(value) == 2
|
|
21
|
+
and all(item is None or isinstance(item, datetime) for item in value)
|
|
22
|
+
and any(item is not None for item in value)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encode_value(value: Any) -> str:
|
|
27
|
+
"""Encode one query value.
|
|
28
|
+
|
|
29
|
+
``bool`` -> ``"true"``/``"false"``; ``datetime`` -> ms; a list or tuple -> CSV;
|
|
30
|
+
a ``(from, to)`` tuple of datetimes (``None`` = open end) -> ``"from,to"`` with ``0``
|
|
31
|
+
for the open end.
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
return "true" if value else "false"
|
|
35
|
+
if isinstance(value, datetime):
|
|
36
|
+
return str(to_ms(value))
|
|
37
|
+
if isinstance(value, tuple) and _is_date_range(value):
|
|
38
|
+
return ",".join("0" if item is None else str(to_ms(item)) for item in value)
|
|
39
|
+
if isinstance(value, (list, tuple)):
|
|
40
|
+
return ",".join(encode_value(item) for item in value)
|
|
41
|
+
return str(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_params(params: Mapping[str, Any]) -> dict[str, str]:
|
|
45
|
+
"""Encode query parameters, dropping those whose value is ``None``."""
|
|
46
|
+
return {key: encode_value(value) for key, value in params.items() if value is not None}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode_json(value: Any) -> Any:
|
|
50
|
+
"""Recursively convert datetimes in a JSON body to ms-epoch integers."""
|
|
51
|
+
if isinstance(value, datetime):
|
|
52
|
+
return to_ms(value)
|
|
53
|
+
if isinstance(value, Mapping):
|
|
54
|
+
return {key: encode_json(item) for key, item in value.items()}
|
|
55
|
+
if isinstance(value, (list, tuple)):
|
|
56
|
+
return [encode_json(item) for item in value]
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def encode_info(fields: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
61
|
+
"""Build the ``info`` array of ``{key, value}`` objects used by write calls.
|
|
62
|
+
|
|
63
|
+
The server only accepts scalar values as strings (a JSON number is answered with
|
|
64
|
+
HTTP 500), so numbers, booleans and datetimes are sent as text. Structured values
|
|
65
|
+
such as ``notes`` keep their JSON shape.
|
|
66
|
+
"""
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
"key": key,
|
|
70
|
+
"value": encode_value(value)
|
|
71
|
+
if isinstance(value, (int, float, datetime))
|
|
72
|
+
else encode_json(value),
|
|
73
|
+
}
|
|
74
|
+
for key, value in fields.items()
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def quote_segment(value: object) -> str:
|
|
79
|
+
"""Percent-encode one URL path segment (asset ids contain ``:``)."""
|
|
80
|
+
return quote(str(value), safe="")
|
sysaid/_unverified.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Switch-off for features that have not been verified against a live SysAid server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import ParamSpec, TypeVar
|
|
8
|
+
|
|
9
|
+
from .exceptions import UnverifiedFeatureError
|
|
10
|
+
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
R = TypeVar("R")
|
|
13
|
+
|
|
14
|
+
# The test suite sets this to ``False`` to keep exercising the disabled code.
|
|
15
|
+
DISABLED = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def unverified(func: Callable[P, R]) -> Callable[P, R]:
|
|
19
|
+
"""Make ``func`` raise :class:`UnverifiedFeatureError` instead of running.
|
|
20
|
+
|
|
21
|
+
Remove the decorator once the call has been verified against a live server.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@wraps(func)
|
|
25
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
26
|
+
if DISABLED:
|
|
27
|
+
raise UnverifiedFeatureError(
|
|
28
|
+
f"{func.__qualname__} is disabled: "
|
|
29
|
+
"it has not been verified against a live SysAid server"
|
|
30
|
+
)
|
|
31
|
+
return func(*args, **kwargs)
|
|
32
|
+
|
|
33
|
+
return wrapper
|
sysaid/auth.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Session-cookie login (``POST /login``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import AuthenticationError, ForbiddenError, UnauthorizedError
|
|
9
|
+
from .models import Record
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import SysAid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LoginResult:
|
|
17
|
+
"""Outcome of a successful login."""
|
|
18
|
+
|
|
19
|
+
logged_in: bool
|
|
20
|
+
user_id: str | None
|
|
21
|
+
language: str | None
|
|
22
|
+
sysaid_version: str | None
|
|
23
|
+
date_format: str | None
|
|
24
|
+
error_msg: str | None
|
|
25
|
+
user: Record | None
|
|
26
|
+
raw: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict[str, Any]) -> LoginResult:
|
|
30
|
+
"""Build from the ``/login`` response body."""
|
|
31
|
+
user = Record(data["user"]) if isinstance(data.get("user"), dict) else None
|
|
32
|
+
user_id = data.get("user_id") or (user.id if user else None)
|
|
33
|
+
return cls(
|
|
34
|
+
logged_in=data.get("logged_in", True) is not False,
|
|
35
|
+
user_id=None if user_id is None else str(user_id),
|
|
36
|
+
language=data.get("language"),
|
|
37
|
+
sysaid_version=data.get("sysaid_version"),
|
|
38
|
+
date_format=data.get("date_format"),
|
|
39
|
+
error_msg=data.get("error_msg"),
|
|
40
|
+
user=user,
|
|
41
|
+
raw=data,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def login(
|
|
46
|
+
client: SysAid, username: str, password: str, account_id: str | None = None
|
|
47
|
+
) -> LoginResult:
|
|
48
|
+
"""Authenticate; the ``JSESSIONID`` cookie is kept by the client's session."""
|
|
49
|
+
body = {"user_name": username, "password": password, "account_id": account_id}
|
|
50
|
+
try:
|
|
51
|
+
data = client.request(
|
|
52
|
+
"POST",
|
|
53
|
+
"/login",
|
|
54
|
+
json={key: value for key, value in body.items() if value is not None},
|
|
55
|
+
auth=False,
|
|
56
|
+
)
|
|
57
|
+
except (UnauthorizedError, ForbiddenError) as exc:
|
|
58
|
+
raise AuthenticationError(exc.message) from exc
|
|
59
|
+
result = LoginResult.from_dict(data if isinstance(data, dict) else {})
|
|
60
|
+
if not result.logged_in:
|
|
61
|
+
raise AuthenticationError(result.error_msg or "login failed")
|
|
62
|
+
return result
|
sysaid/client.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The :class:`SysAid` client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from . import auth as _auth
|
|
12
|
+
from ._params import build_params, encode_json
|
|
13
|
+
from ._unverified import unverified
|
|
14
|
+
from .exceptions import AuthenticationError, error_from_response
|
|
15
|
+
from .resources.action_items import ActionItems
|
|
16
|
+
from .resources.addons import Addons
|
|
17
|
+
from .resources.assets import Assets
|
|
18
|
+
from .resources.cis import CIs
|
|
19
|
+
from .resources.filters import Filters
|
|
20
|
+
from .resources.lists import Lists
|
|
21
|
+
from .resources.password_services import PasswordServices
|
|
22
|
+
from .resources.reports import Reports
|
|
23
|
+
from .resources.resource_bundle import ResourceBundle
|
|
24
|
+
from .resources.service_requests import ServiceRequests
|
|
25
|
+
from .resources.users import Users
|
|
26
|
+
|
|
27
|
+
API_PATH = "/api/v1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def api_url_for(base_url: str) -> str:
|
|
31
|
+
"""``https://host`` or ``https://host/api/v1`` -> ``https://host/api/v1``."""
|
|
32
|
+
base = base_url.rstrip("/")
|
|
33
|
+
return base if base.endswith(API_PATH) else base + API_PATH
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SysAid:
|
|
37
|
+
"""Client for the SysAid REST API.
|
|
38
|
+
|
|
39
|
+
Credentials are optional: with ``username`` and ``password`` the client logs in
|
|
40
|
+
on first use; without them only unauthenticated calls (password services) work.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
base_url: str,
|
|
46
|
+
username: str | None = None,
|
|
47
|
+
password: str | None = None,
|
|
48
|
+
account_id: str | None = None,
|
|
49
|
+
timeout: float | None = 30,
|
|
50
|
+
verify: bool | str = True,
|
|
51
|
+
session: requests.Session | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.api_url = api_url_for(base_url)
|
|
54
|
+
self.account_id = account_id
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.session = session or requests.Session()
|
|
57
|
+
self.session.verify = verify
|
|
58
|
+
self._username = username
|
|
59
|
+
self._password = password
|
|
60
|
+
self._logged_in = False
|
|
61
|
+
self.users = Users(self)
|
|
62
|
+
self.filters = Filters(self)
|
|
63
|
+
self.lists = Lists(self)
|
|
64
|
+
self.service_requests = ServiceRequests(self)
|
|
65
|
+
self.action_items = ActionItems(self)
|
|
66
|
+
self.assets = Assets(self)
|
|
67
|
+
self.cis = CIs(self)
|
|
68
|
+
self.addons = Addons(self)
|
|
69
|
+
self.resource_bundle = ResourceBundle(self)
|
|
70
|
+
self.password_services = PasswordServices(self)
|
|
71
|
+
self.reports = Reports(self)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
@unverified
|
|
75
|
+
def from_oauth(
|
|
76
|
+
cls,
|
|
77
|
+
base_url: str,
|
|
78
|
+
consumer_key: str,
|
|
79
|
+
access_token: str,
|
|
80
|
+
access_token_secret: str,
|
|
81
|
+
consumer_secret: str = "",
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> SysAid:
|
|
84
|
+
"""A client that signs every request with OAuth 1.0 (needs ``python-sysaid[oauth]``)."""
|
|
85
|
+
from .oauth import oauth1
|
|
86
|
+
|
|
87
|
+
client = cls(base_url, **kwargs)
|
|
88
|
+
client.session.auth = oauth1(
|
|
89
|
+
consumer_key,
|
|
90
|
+
consumer_secret,
|
|
91
|
+
resource_owner_key=access_token,
|
|
92
|
+
resource_owner_secret=access_token_secret,
|
|
93
|
+
)
|
|
94
|
+
return client
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return f"SysAid({self.api_url!r}, username={self._username!r})"
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> SysAid:
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(
|
|
103
|
+
self,
|
|
104
|
+
exc_type: type[BaseException] | None,
|
|
105
|
+
exc: BaseException | None,
|
|
106
|
+
tb: TracebackType | None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
"""Close the underlying HTTP session."""
|
|
112
|
+
self.session.close()
|
|
113
|
+
self._logged_in = False
|
|
114
|
+
|
|
115
|
+
def login(self) -> _auth.LoginResult:
|
|
116
|
+
"""Log in with the configured credentials."""
|
|
117
|
+
if self._username is None or self._password is None:
|
|
118
|
+
raise AuthenticationError("username and password are required to log in")
|
|
119
|
+
result = _auth.login(self, self._username, self._password, self.account_id)
|
|
120
|
+
self._logged_in = True
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
def request(
|
|
124
|
+
self,
|
|
125
|
+
method: str,
|
|
126
|
+
path: str,
|
|
127
|
+
*,
|
|
128
|
+
params: Mapping[str, Any] | None = None,
|
|
129
|
+
json: Any = None,
|
|
130
|
+
data: Mapping[str, Any] | None = None,
|
|
131
|
+
files: Any = None,
|
|
132
|
+
auth: bool = True,
|
|
133
|
+
raw: bool = False,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Send a request and return the decoded JSON (text if not JSON, ``None`` if empty).
|
|
136
|
+
|
|
137
|
+
With ``raw=True`` the :class:`requests.Response` is returned instead.
|
|
138
|
+
With ``auth=False`` no login is attempted first.
|
|
139
|
+
"""
|
|
140
|
+
if auth and not self._logged_in and self._username is not None:
|
|
141
|
+
self.login()
|
|
142
|
+
response = self.session.request(
|
|
143
|
+
method,
|
|
144
|
+
self.api_url + "/" + path.lstrip("/"),
|
|
145
|
+
params=build_params(params or {}),
|
|
146
|
+
json=encode_json(json),
|
|
147
|
+
data=data,
|
|
148
|
+
files=files,
|
|
149
|
+
timeout=self.timeout,
|
|
150
|
+
)
|
|
151
|
+
if not response.ok:
|
|
152
|
+
raise error_from_response(response)
|
|
153
|
+
if raw:
|
|
154
|
+
return response
|
|
155
|
+
if not response.content:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return response.json()
|
|
159
|
+
except ValueError:
|
|
160
|
+
return response.text
|