glpi-utils 1.2.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.
- glpi_utils/__init__.py +63 -0
- glpi_utils/_resource.py +162 -0
- glpi_utils/aio.py +463 -0
- glpi_utils/api.py +745 -0
- glpi_utils/exceptions.py +68 -0
- glpi_utils/logger.py +171 -0
- glpi_utils/oauth.py +794 -0
- glpi_utils/version.py +120 -0
- glpi_utils-1.2.0.dist-info/METADATA +423 -0
- glpi_utils-1.2.0.dist-info/RECORD +13 -0
- glpi_utils-1.2.0.dist-info/WHEEL +5 -0
- glpi_utils-1.2.0.dist-info/licenses/LICENSE +21 -0
- glpi_utils-1.2.0.dist-info/top_level.txt +1 -0
glpi_utils/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
glpi_utils
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Python library for the GLPI 11 REST API.
|
|
6
|
+
|
|
7
|
+
Clients
|
|
8
|
+
-------
|
|
9
|
+
* :class:`GlpiAPI` – Sync client, legacy API (``/apirest.php``)
|
|
10
|
+
* :class:`AsyncGlpiAPI` – Async client, legacy API (``/apirest.php``)
|
|
11
|
+
* :class:`GlpiOAuthClient` – Sync client, high-level API (``/api.php``, OAuth2)
|
|
12
|
+
* :class:`AsyncGlpiOAuthClient`– Async client, high-level API (``/api.php``, OAuth2)
|
|
13
|
+
|
|
14
|
+
Exceptions
|
|
15
|
+
----------
|
|
16
|
+
* :exc:`GlpiError` – Base exception
|
|
17
|
+
* :exc:`GlpiAPIError` – API-level error (includes status_code, error_code)
|
|
18
|
+
* :exc:`GlpiAuthError` – Authentication / session errors
|
|
19
|
+
* :exc:`GlpiNotFoundError` – 404 Not Found
|
|
20
|
+
* :exc:`GlpiPermissionError` – 403 Forbidden
|
|
21
|
+
* :exc:`GlpiConnectionError` – Network/connectivity errors
|
|
22
|
+
|
|
23
|
+
Utilities
|
|
24
|
+
---------
|
|
25
|
+
* :class:`GLPIVersion` – Comparable version helper
|
|
26
|
+
* :class:`SensitiveFilter` – Logging filter that masks credentials
|
|
27
|
+
* :class:`EmptyHandler` – Silent handler (library default)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from .api import GlpiAPI
|
|
31
|
+
from .aio import AsyncGlpiAPI
|
|
32
|
+
from .oauth import AsyncGlpiOAuthClient, GlpiOAuthClient
|
|
33
|
+
from .exceptions import (
|
|
34
|
+
GlpiAPIError,
|
|
35
|
+
GlpiAuthError,
|
|
36
|
+
GlpiConnectionError,
|
|
37
|
+
GlpiError,
|
|
38
|
+
GlpiNotFoundError,
|
|
39
|
+
GlpiPermissionError,
|
|
40
|
+
)
|
|
41
|
+
from .logger import EmptyHandler, SensitiveFilter
|
|
42
|
+
from .version import GLPIVersion
|
|
43
|
+
|
|
44
|
+
__version__ = "1.2.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Clients
|
|
48
|
+
"GlpiAPI",
|
|
49
|
+
"AsyncGlpiAPI",
|
|
50
|
+
"GlpiOAuthClient",
|
|
51
|
+
"AsyncGlpiOAuthClient",
|
|
52
|
+
# Exceptions
|
|
53
|
+
"GlpiError",
|
|
54
|
+
"GlpiAPIError",
|
|
55
|
+
"GlpiAuthError",
|
|
56
|
+
"GlpiNotFoundError",
|
|
57
|
+
"GlpiPermissionError",
|
|
58
|
+
"GlpiConnectionError",
|
|
59
|
+
# Utilities
|
|
60
|
+
"GLPIVersion",
|
|
61
|
+
"SensitiveFilter",
|
|
62
|
+
"EmptyHandler",
|
|
63
|
+
]
|
glpi_utils/_resource.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
glpi_utils._resource
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
ItemProxy and AsyncItemProxy – thin accessor objects that bind an item-type
|
|
6
|
+
name to a session so callers can write ``api.ticket.get(1)`` instead of
|
|
7
|
+
calling lower-level methods directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .api import GlpiAPI
|
|
16
|
+
from .aio import AsyncGlpiAPI
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ItemProxy:
|
|
20
|
+
"""Synchronous resource accessor for a single GLPI item-type."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, session: "GlpiAPI", itemtype: str) -> None:
|
|
23
|
+
self._session = session
|
|
24
|
+
self._itemtype = itemtype
|
|
25
|
+
|
|
26
|
+
def get(self, item_id: int, **kwargs: Any) -> dict:
|
|
27
|
+
"""Return a single item by *item_id*."""
|
|
28
|
+
return self._session.get_item(self._itemtype, item_id, **kwargs)
|
|
29
|
+
|
|
30
|
+
def get_all(self, **kwargs: Any) -> list:
|
|
31
|
+
"""Return a single page of items (default range ``0-49``).
|
|
32
|
+
|
|
33
|
+
Use :meth:`get_all_pages` to fetch every item automatically.
|
|
34
|
+
"""
|
|
35
|
+
return self._session.get_all_items(self._itemtype, **kwargs)
|
|
36
|
+
|
|
37
|
+
def get_all_pages(self, page_size: int = 50, **kwargs: Any) -> list:
|
|
38
|
+
"""Fetch **all** items across all pages automatically.
|
|
39
|
+
|
|
40
|
+
Examples
|
|
41
|
+
--------
|
|
42
|
+
::
|
|
43
|
+
|
|
44
|
+
all_tickets = api.ticket.get_all_pages()
|
|
45
|
+
open_tickets = api.ticket.get_all_pages(searchText={"status": "1"})
|
|
46
|
+
"""
|
|
47
|
+
return self._session.get_all_pages(self._itemtype, page_size=page_size, **kwargs)
|
|
48
|
+
|
|
49
|
+
def iter_pages(self, page_size: int = 50, **kwargs: Any) -> Iterator[list]:
|
|
50
|
+
"""Yield one page at a time — memory-efficient for large datasets.
|
|
51
|
+
|
|
52
|
+
Examples
|
|
53
|
+
--------
|
|
54
|
+
::
|
|
55
|
+
|
|
56
|
+
for page in api.ticket.iter_pages(page_size=100):
|
|
57
|
+
for ticket in page:
|
|
58
|
+
process(ticket)
|
|
59
|
+
"""
|
|
60
|
+
return self._session.iter_pages(self._itemtype, page_size=page_size, **kwargs)
|
|
61
|
+
|
|
62
|
+
def search(self, **kwargs: Any) -> dict:
|
|
63
|
+
"""Run the GLPI search engine against this item-type."""
|
|
64
|
+
return self._session.search(self._itemtype, **kwargs)
|
|
65
|
+
|
|
66
|
+
def create(self, input_data: Any, **kwargs: Any) -> Any:
|
|
67
|
+
"""Create one or several items."""
|
|
68
|
+
return self._session.create_item(self._itemtype, input_data, **kwargs)
|
|
69
|
+
|
|
70
|
+
def update(self, input_data: Any, **kwargs: Any) -> list:
|
|
71
|
+
"""Update one or several items. Each dict must contain an ``"id"`` key."""
|
|
72
|
+
return self._session.update_item(self._itemtype, input_data, **kwargs)
|
|
73
|
+
|
|
74
|
+
def delete(
|
|
75
|
+
self,
|
|
76
|
+
input_data: Any,
|
|
77
|
+
force_purge: bool = False,
|
|
78
|
+
history: bool = True,
|
|
79
|
+
) -> list:
|
|
80
|
+
"""Delete one or several items."""
|
|
81
|
+
return self._session.delete_item(
|
|
82
|
+
self._itemtype, input_data,
|
|
83
|
+
force_purge=force_purge, history=history,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def get_sub_items(self, item_id: int, sub_itemtype: str, **kwargs: Any) -> list:
|
|
87
|
+
"""Return sub-items of *sub_itemtype* for the given parent *item_id*."""
|
|
88
|
+
return self._session.get_sub_items(
|
|
89
|
+
self._itemtype, item_id, sub_itemtype, **kwargs
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def add_sub_item(
|
|
93
|
+
self, item_id: int, sub_itemtype: str, input_data: dict, **kwargs: Any
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Add a sub-item (e.g. a followup or task) to a parent item."""
|
|
96
|
+
return self._session.add_sub_item(
|
|
97
|
+
self._itemtype, item_id, sub_itemtype, input_data, **kwargs
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"ItemProxy(itemtype={self._itemtype!r})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AsyncItemProxy:
|
|
105
|
+
"""Asynchronous resource accessor for a single GLPI item-type."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, session: "AsyncGlpiAPI", itemtype: str) -> None:
|
|
108
|
+
self._session = session
|
|
109
|
+
self._itemtype = itemtype
|
|
110
|
+
|
|
111
|
+
async def get(self, item_id: int, **kwargs: Any) -> dict:
|
|
112
|
+
return await self._session.get_item(self._itemtype, item_id, **kwargs)
|
|
113
|
+
|
|
114
|
+
async def get_all(self, **kwargs: Any) -> list:
|
|
115
|
+
"""Return a single page of items (default range ``0-49``)."""
|
|
116
|
+
return await self._session.get_all_items(self._itemtype, **kwargs)
|
|
117
|
+
|
|
118
|
+
async def get_all_pages(self, page_size: int = 50, **kwargs: Any) -> list:
|
|
119
|
+
"""Fetch **all** items across all pages automatically."""
|
|
120
|
+
return await self._session.get_all_pages(self._itemtype, page_size=page_size, **kwargs)
|
|
121
|
+
|
|
122
|
+
async def iter_pages(self, page_size: int = 50, **kwargs: Any) -> AsyncIterator[list]:
|
|
123
|
+
"""Yield one page at a time asynchronously."""
|
|
124
|
+
async for page in self._session.iter_pages(self._itemtype, page_size=page_size, **kwargs):
|
|
125
|
+
yield page
|
|
126
|
+
|
|
127
|
+
async def search(self, **kwargs: Any) -> dict:
|
|
128
|
+
return await self._session.search(self._itemtype, **kwargs)
|
|
129
|
+
|
|
130
|
+
async def create(self, input_data: Any, **kwargs: Any) -> Any:
|
|
131
|
+
return await self._session.create_item(self._itemtype, input_data, **kwargs)
|
|
132
|
+
|
|
133
|
+
async def update(self, input_data: Any, **kwargs: Any) -> list:
|
|
134
|
+
return await self._session.update_item(self._itemtype, input_data, **kwargs)
|
|
135
|
+
|
|
136
|
+
async def delete(
|
|
137
|
+
self,
|
|
138
|
+
input_data: Any,
|
|
139
|
+
force_purge: bool = False,
|
|
140
|
+
history: bool = True,
|
|
141
|
+
) -> list:
|
|
142
|
+
return await self._session.delete_item(
|
|
143
|
+
self._itemtype, input_data,
|
|
144
|
+
force_purge=force_purge, history=history,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def get_sub_items(
|
|
148
|
+
self, item_id: int, sub_itemtype: str, **kwargs: Any
|
|
149
|
+
) -> list:
|
|
150
|
+
return await self._session.get_sub_items(
|
|
151
|
+
self._itemtype, item_id, sub_itemtype, **kwargs
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def add_sub_item(
|
|
155
|
+
self, item_id: int, sub_itemtype: str, input_data: dict, **kwargs: Any
|
|
156
|
+
) -> dict:
|
|
157
|
+
return await self._session.add_sub_item(
|
|
158
|
+
self._itemtype, item_id, sub_itemtype, input_data, **kwargs
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def __repr__(self) -> str:
|
|
162
|
+
return f"AsyncItemProxy(itemtype={self._itemtype!r})"
|
glpi_utils/aio.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""
|
|
2
|
+
glpi_utils.aio
|
|
3
|
+
~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Asynchronous GLPI REST API client powered by ``aiohttp``.
|
|
6
|
+
|
|
7
|
+
The public interface mirrors :class:`~glpi_utils.api.GlpiAPI` exactly so
|
|
8
|
+
callers can swap between sync and async with minimal changes.
|
|
9
|
+
|
|
10
|
+
Requires the optional ``aiohttp`` dependency::
|
|
11
|
+
|
|
12
|
+
pip install glpi-utils[async]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from base64 import b64encode
|
|
20
|
+
from typing import Any, AsyncIterator, Optional
|
|
21
|
+
|
|
22
|
+
from ._resource import AsyncItemProxy
|
|
23
|
+
from .api import DEFAULT_PAGE_SIZE, _ITEMTYPE_MAP, _boolify_params, _parse_content_range, _raise_for_glpi_error
|
|
24
|
+
from .exceptions import GlpiAuthError, GlpiConnectionError
|
|
25
|
+
from .logger import EmptyHandler, SensitiveFilter
|
|
26
|
+
from .version import GLPIVersion
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
log.addHandler(EmptyHandler())
|
|
30
|
+
log.addFilter(SensitiveFilter())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncGlpiAPI:
|
|
34
|
+
"""Asynchronous client for the GLPI 11 legacy REST API (``/apirest.php``).
|
|
35
|
+
|
|
36
|
+
Must be used with ``await``::
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
from glpi_utils import AsyncGlpiAPI
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
api = AsyncGlpiAPI(url="https://glpi.example.com")
|
|
43
|
+
await api.login(username="glpi", password="glpi")
|
|
44
|
+
|
|
45
|
+
# Single page
|
|
46
|
+
tickets = await api.ticket.get_all()
|
|
47
|
+
|
|
48
|
+
# All pages automatically
|
|
49
|
+
all_tickets = await api.ticket.get_all_pages()
|
|
50
|
+
|
|
51
|
+
# Memory-efficient iteration
|
|
52
|
+
async for page in api.ticket.iter_pages(page_size=100):
|
|
53
|
+
for ticket in page:
|
|
54
|
+
process(ticket)
|
|
55
|
+
|
|
56
|
+
await api.logout()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
|
|
60
|
+
Can also be used as an async context manager::
|
|
61
|
+
|
|
62
|
+
async with AsyncGlpiAPI(url="https://glpi.example.com") as api:
|
|
63
|
+
await api.login(username="glpi", password="glpi")
|
|
64
|
+
version = await api.get_version()
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
url : str or None
|
|
69
|
+
app_token : str or None
|
|
70
|
+
verify_ssl : bool
|
|
71
|
+
timeout : int
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
url: Optional[str] = None,
|
|
77
|
+
app_token: Optional[str] = None,
|
|
78
|
+
verify_ssl: bool = True,
|
|
79
|
+
timeout: int = 30,
|
|
80
|
+
) -> None:
|
|
81
|
+
try:
|
|
82
|
+
import aiohttp # noqa: F401
|
|
83
|
+
except ImportError as exc:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"AsyncGlpiAPI requires 'aiohttp'. "
|
|
86
|
+
"Install it with: pip install glpi-utils[async]"
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
self._url = (url or os.environ.get("GLPI_URL", "")).rstrip("/")
|
|
90
|
+
if not self._url:
|
|
91
|
+
raise ValueError("A GLPI URL is required. Pass url= or set GLPI_URL.")
|
|
92
|
+
self._app_token = app_token or os.environ.get("GLPI_APP_TOKEN")
|
|
93
|
+
self._verify_ssl = verify_ssl
|
|
94
|
+
self._timeout = timeout
|
|
95
|
+
|
|
96
|
+
self._session_token: Optional[str] = None
|
|
97
|
+
self._http: Any = None
|
|
98
|
+
self._version: Optional[GLPIVersion] = None
|
|
99
|
+
self._proxies: dict = {}
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Async context manager
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def __aenter__(self) -> "AsyncGlpiAPI":
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
109
|
+
if self._session_token:
|
|
110
|
+
try:
|
|
111
|
+
await self.logout()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
if self._http and not self._http.closed:
|
|
115
|
+
await self._http.close()
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Fluent accessors
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def __getattr__(self, name: str) -> AsyncItemProxy:
|
|
122
|
+
lower = name.lower()
|
|
123
|
+
if lower in _ITEMTYPE_MAP:
|
|
124
|
+
if lower not in self._proxies:
|
|
125
|
+
self._proxies[lower] = AsyncItemProxy(self, _ITEMTYPE_MAP[lower])
|
|
126
|
+
return self._proxies[lower]
|
|
127
|
+
raise AttributeError(
|
|
128
|
+
f"{self.__class__.__name__!r} has no attribute {name!r}. "
|
|
129
|
+
"Use api.item('YourItemtype') for non-standard item types."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def item(self, itemtype: str) -> AsyncItemProxy:
|
|
133
|
+
"""Return an :class:`~glpi_utils._resource.AsyncItemProxy` for any itemtype."""
|
|
134
|
+
if itemtype not in self._proxies:
|
|
135
|
+
self._proxies[itemtype] = AsyncItemProxy(self, itemtype)
|
|
136
|
+
return self._proxies[itemtype]
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Internal HTTP helpers
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def _base_url(self) -> str:
|
|
144
|
+
return f"{self._url}/apirest.php"
|
|
145
|
+
|
|
146
|
+
def _get_http(self) -> Any:
|
|
147
|
+
"""Lazily create the aiohttp ClientSession."""
|
|
148
|
+
import aiohttp
|
|
149
|
+
|
|
150
|
+
if self._http is None or self._http.closed:
|
|
151
|
+
connector = aiohttp.TCPConnector(ssl=self._verify_ssl)
|
|
152
|
+
self._http = aiohttp.ClientSession(connector=connector)
|
|
153
|
+
return self._http
|
|
154
|
+
|
|
155
|
+
def _default_headers(self) -> dict:
|
|
156
|
+
headers: dict = {"Content-Type": "application/json"}
|
|
157
|
+
if self._app_token:
|
|
158
|
+
headers["App-Token"] = self._app_token
|
|
159
|
+
if self._session_token:
|
|
160
|
+
headers["Session-Token"] = self._session_token
|
|
161
|
+
return headers
|
|
162
|
+
|
|
163
|
+
async def _request(
|
|
164
|
+
self,
|
|
165
|
+
method: str,
|
|
166
|
+
path: str,
|
|
167
|
+
*,
|
|
168
|
+
headers: Optional[dict] = None,
|
|
169
|
+
params: Optional[dict] = None,
|
|
170
|
+
json: Any = None,
|
|
171
|
+
) -> Any:
|
|
172
|
+
body, _ = await self._request_with_headers(
|
|
173
|
+
method, path, headers=headers, params=params, json=json
|
|
174
|
+
)
|
|
175
|
+
return body
|
|
176
|
+
|
|
177
|
+
async def _request_with_headers(
|
|
178
|
+
self,
|
|
179
|
+
method: str,
|
|
180
|
+
path: str,
|
|
181
|
+
*,
|
|
182
|
+
headers: Optional[dict] = None,
|
|
183
|
+
params: Optional[dict] = None,
|
|
184
|
+
json: Any = None,
|
|
185
|
+
) -> tuple:
|
|
186
|
+
"""Like ``_request`` but returns ``(body, response_headers)``."""
|
|
187
|
+
import aiohttp
|
|
188
|
+
|
|
189
|
+
url = f"{self._base_url}/{path.lstrip('/')}"
|
|
190
|
+
merged_headers = {**self._default_headers(), **(headers or {})}
|
|
191
|
+
|
|
192
|
+
log.debug("ASYNC %s %s params=%s", method.upper(), url, params)
|
|
193
|
+
|
|
194
|
+
http = self._get_http()
|
|
195
|
+
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
async with http.request(
|
|
199
|
+
method, url,
|
|
200
|
+
headers=merged_headers,
|
|
201
|
+
params=params,
|
|
202
|
+
json=json,
|
|
203
|
+
timeout=timeout,
|
|
204
|
+
) as response:
|
|
205
|
+
status = response.status
|
|
206
|
+
resp_headers = dict(response.headers)
|
|
207
|
+
log.debug("Response %s from %s", status, url)
|
|
208
|
+
|
|
209
|
+
if status == 204 or response.content_length == 0:
|
|
210
|
+
return None, resp_headers
|
|
211
|
+
|
|
212
|
+
body = await response.json(content_type=None)
|
|
213
|
+
|
|
214
|
+
class _FakeResponse:
|
|
215
|
+
status_code = status
|
|
216
|
+
content = True
|
|
217
|
+
|
|
218
|
+
def json(self_):
|
|
219
|
+
return body
|
|
220
|
+
|
|
221
|
+
text = str(body)
|
|
222
|
+
|
|
223
|
+
_raise_for_glpi_error(_FakeResponse()) # type: ignore[arg-type]
|
|
224
|
+
return body, resp_headers
|
|
225
|
+
|
|
226
|
+
except aiohttp.ClientConnectorError as exc:
|
|
227
|
+
raise GlpiConnectionError(f"Cannot reach GLPI at {self._url}: {exc}") from exc
|
|
228
|
+
except aiohttp.ServerTimeoutError as exc:
|
|
229
|
+
raise GlpiConnectionError(f"Request timed out: {exc}") from exc
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# Authentication
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
async def login(
|
|
236
|
+
self,
|
|
237
|
+
username: Optional[str] = None,
|
|
238
|
+
password: Optional[str] = None,
|
|
239
|
+
user_token: Optional[str] = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Authenticate and obtain a session token."""
|
|
242
|
+
username = username or os.environ.get("GLPI_USER")
|
|
243
|
+
password = password or os.environ.get("GLPI_PASSWORD")
|
|
244
|
+
user_token = user_token or os.environ.get("GLPI_USER_TOKEN")
|
|
245
|
+
|
|
246
|
+
auth_headers: dict = {}
|
|
247
|
+
|
|
248
|
+
if user_token:
|
|
249
|
+
auth_headers["Authorization"] = f"user_token {user_token}"
|
|
250
|
+
elif username and password:
|
|
251
|
+
credentials = b64encode(f"{username}:{password}".encode()).decode()
|
|
252
|
+
auth_headers["Authorization"] = f"Basic {credentials}"
|
|
253
|
+
else:
|
|
254
|
+
raise GlpiAuthError("Provide username+password or user_token.")
|
|
255
|
+
|
|
256
|
+
data = await self._request("GET", "initSession", headers=auth_headers)
|
|
257
|
+
self._session_token = data["session_token"]
|
|
258
|
+
log.debug("Async session established.")
|
|
259
|
+
|
|
260
|
+
async def logout(self) -> None:
|
|
261
|
+
"""Terminate the active GLPI session."""
|
|
262
|
+
if self._session_token:
|
|
263
|
+
try:
|
|
264
|
+
await self._request("GET", "killSession")
|
|
265
|
+
finally:
|
|
266
|
+
self._session_token = None
|
|
267
|
+
log.debug("Async session terminated.")
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Version
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def version(self) -> Optional[GLPIVersion]:
|
|
275
|
+
"""Return cached version (call ``await api.get_version()`` to fetch)."""
|
|
276
|
+
return self._version
|
|
277
|
+
|
|
278
|
+
async def get_version(self) -> GLPIVersion:
|
|
279
|
+
"""Fetch and cache the GLPI server version."""
|
|
280
|
+
data = await self._request("GET", "getGlpiVersion")
|
|
281
|
+
self._version = GLPIVersion(data.get("glpi_version", "0.0.0"))
|
|
282
|
+
return self._version
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Session utilities
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
async def get_my_profiles(self) -> list:
|
|
289
|
+
return (await self._request("GET", "getMyProfiles"))["myprofiles"]
|
|
290
|
+
|
|
291
|
+
async def get_active_profile(self) -> dict:
|
|
292
|
+
return (await self._request("GET", "getActiveProfile"))["active_profile"]
|
|
293
|
+
|
|
294
|
+
async def set_active_profile(self, profile_id: int) -> None:
|
|
295
|
+
await self._request("POST", "changeActiveProfile", json={"profiles_id": profile_id})
|
|
296
|
+
|
|
297
|
+
async def get_my_entities(self, is_recursive: bool = False) -> list:
|
|
298
|
+
return (
|
|
299
|
+
await self._request(
|
|
300
|
+
"GET", "getMyEntities", params={"is_recursive": int(is_recursive)}
|
|
301
|
+
)
|
|
302
|
+
)["myentities"]
|
|
303
|
+
|
|
304
|
+
async def get_active_entities(self) -> dict:
|
|
305
|
+
return (await self._request("GET", "getActiveEntities"))["active_entity"]
|
|
306
|
+
|
|
307
|
+
async def set_active_entity(self, entity_id: int, is_recursive: bool = False) -> None:
|
|
308
|
+
await self._request(
|
|
309
|
+
"POST", "changeActiveEntities",
|
|
310
|
+
json={"entities_id": entity_id, "is_recursive": int(is_recursive)},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def get_full_session(self) -> dict:
|
|
314
|
+
return (await self._request("GET", "getFullSession"))["session"]
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# Item CRUD
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
async def get_item(self, itemtype: str, item_id: int, **kwargs: Any) -> dict:
|
|
321
|
+
params = _boolify_params(kwargs)
|
|
322
|
+
return await self._request("GET", f"{itemtype}/{item_id}", params=params)
|
|
323
|
+
|
|
324
|
+
async def get_all_items(self, itemtype: str, **kwargs: Any) -> list:
|
|
325
|
+
"""Return a single page of items (default range ``0-49``).
|
|
326
|
+
|
|
327
|
+
Use :meth:`get_all_pages` to retrieve all items automatically.
|
|
328
|
+
"""
|
|
329
|
+
params = _boolify_params(kwargs)
|
|
330
|
+
if "range" not in params:
|
|
331
|
+
params["range"] = f"0-{DEFAULT_PAGE_SIZE - 1}"
|
|
332
|
+
return await self._request("GET", itemtype, params=params)
|
|
333
|
+
|
|
334
|
+
async def get_all_pages(
|
|
335
|
+
self,
|
|
336
|
+
itemtype: str,
|
|
337
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
338
|
+
**kwargs: Any,
|
|
339
|
+
) -> list:
|
|
340
|
+
"""Fetch **all** items of *itemtype* by iterating pages automatically.
|
|
341
|
+
|
|
342
|
+
Parameters
|
|
343
|
+
----------
|
|
344
|
+
itemtype : str
|
|
345
|
+
page_size : int
|
|
346
|
+
Items per request (default: 50).
|
|
347
|
+
**kwargs
|
|
348
|
+
Extra GLPI parameters: ``sort``, ``order``, ``searchText``,
|
|
349
|
+
``is_deleted``, ``expand_dropdowns``, etc.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
list
|
|
354
|
+
All matching items as a flat list of dicts.
|
|
355
|
+
"""
|
|
356
|
+
results: list = []
|
|
357
|
+
async for page in self.iter_pages(itemtype, page_size=page_size, **kwargs):
|
|
358
|
+
results.extend(page)
|
|
359
|
+
return results
|
|
360
|
+
|
|
361
|
+
async def iter_pages(
|
|
362
|
+
self,
|
|
363
|
+
itemtype: str,
|
|
364
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
365
|
+
**kwargs: Any,
|
|
366
|
+
) -> AsyncIterator[list]:
|
|
367
|
+
"""Yield one page of items at a time asynchronously.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
itemtype : str
|
|
372
|
+
page_size : int
|
|
373
|
+
**kwargs
|
|
374
|
+
Same as :meth:`get_all_pages`.
|
|
375
|
+
|
|
376
|
+
Yields
|
|
377
|
+
------
|
|
378
|
+
list
|
|
379
|
+
One page per iteration.
|
|
380
|
+
"""
|
|
381
|
+
params = _boolify_params(kwargs)
|
|
382
|
+
start = 0
|
|
383
|
+
fetched = 0
|
|
384
|
+
|
|
385
|
+
while True:
|
|
386
|
+
end = start + page_size - 1
|
|
387
|
+
params["range"] = f"{start}-{end}"
|
|
388
|
+
|
|
389
|
+
page_items, resp_headers = await self._request_with_headers(
|
|
390
|
+
"GET", itemtype, params=params
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if not page_items:
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
fetched += len(page_items)
|
|
397
|
+
yield page_items
|
|
398
|
+
|
|
399
|
+
total = _parse_content_range(resp_headers.get("Content-Range", ""))
|
|
400
|
+
|
|
401
|
+
if total is not None and fetched >= total:
|
|
402
|
+
return
|
|
403
|
+
if len(page_items) < page_size:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
start += page_size
|
|
407
|
+
|
|
408
|
+
async def search(self, itemtype: str, **kwargs: Any) -> dict:
|
|
409
|
+
params = _boolify_params(kwargs)
|
|
410
|
+
return await self._request("GET", f"search/{itemtype}", params=params)
|
|
411
|
+
|
|
412
|
+
async def create_item(self, itemtype: str, input_data: Any, **kwargs: Any) -> Any:
|
|
413
|
+
payload: dict = {"input": input_data}
|
|
414
|
+
payload.update(kwargs)
|
|
415
|
+
return await self._request("POST", itemtype, json=payload)
|
|
416
|
+
|
|
417
|
+
async def update_item(self, itemtype: str, input_data: Any, **kwargs: Any) -> list:
|
|
418
|
+
payload: dict = {"input": input_data}
|
|
419
|
+
payload.update(kwargs)
|
|
420
|
+
return await self._request("PUT", itemtype, json=payload)
|
|
421
|
+
|
|
422
|
+
async def delete_item(
|
|
423
|
+
self,
|
|
424
|
+
itemtype: str,
|
|
425
|
+
input_data: Any,
|
|
426
|
+
force_purge: bool = False,
|
|
427
|
+
history: bool = True,
|
|
428
|
+
) -> list:
|
|
429
|
+
payload: dict = {
|
|
430
|
+
"input": input_data,
|
|
431
|
+
"force_purge": int(force_purge),
|
|
432
|
+
"history": int(history),
|
|
433
|
+
}
|
|
434
|
+
return await self._request("DELETE", itemtype, json=payload)
|
|
435
|
+
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
# Sub-items
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
async def get_sub_items(
|
|
441
|
+
self, itemtype: str, item_id: int, sub_itemtype: str, **kwargs: Any
|
|
442
|
+
) -> list:
|
|
443
|
+
params = _boolify_params(kwargs)
|
|
444
|
+
return await self._request(
|
|
445
|
+
"GET", f"{itemtype}/{item_id}/{sub_itemtype}", params=params
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
async def add_sub_item(
|
|
449
|
+
self,
|
|
450
|
+
itemtype: str,
|
|
451
|
+
item_id: int,
|
|
452
|
+
sub_itemtype: str,
|
|
453
|
+
input_data: dict,
|
|
454
|
+
**kwargs: Any,
|
|
455
|
+
) -> dict:
|
|
456
|
+
payload: dict = {"input": input_data}
|
|
457
|
+
payload.update(kwargs)
|
|
458
|
+
return await self._request(
|
|
459
|
+
"POST", f"{itemtype}/{item_id}/{sub_itemtype}", json=payload
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def list_item_types(self) -> list:
|
|
463
|
+
return await self._request("GET", "listItemtypes")
|