From 619e7e18d5572724aa20e004efc23998c6ac76ec Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 27 Jul 2026 15:46:17 +0200 Subject: [PATCH 1/9] feat(transport)!: replace requests with httpx and type network faults Swaps the HTTP transport to httpx across the v2 transport, the legacy v1 session and the OAuth token manager in one change. They share _http.py, and the reason-phrase read there is covered only by the v1 tests, so porting them separately would have left the shared code validated exclusively by tests exercising the old transport. Behaviour is preserved. Three places where the libraries disagree silently needed explicit correction, all found by probing rather than by reading docs: * Query params with a None value are dropped again. httpx encodes them as a valueless `key=`, and GLPI reads an empty filter value as "match everything" -- the swap would have quietly widened queries. A test already named `test_request_params_drops_none_values` asserted the opposite of its own name; requests had been dropping them at the transport layer, so nothing noticed. * bytes and bool params keep their previous rendering. httpx emits the Python repr `b'x'` and a lowercase `true`. * Redirects are still followed; httpx does not follow them by default. Network faults now raise GlpiTransportError / GlpiTimeoutError with the transport exception attached as __cause__, delivering the contract the previous release documented but could not yet honour: catching GlpiError covers the whole failure surface and users never import the HTTP library. The retry predicates move onto that library-owned type in the same commit. This was the migration's sharpest edge: the two libraries' exception trees are disjoint, so a predicate still naming the old base matches nothing and every retry vanishes -- silently, with a green suite. Naming a type the library raises itself makes it unreproducible. Verified by mutation: reverting the predicate fails 7 tests spanning all three transports. Also drops requests/urllib3/types-requests, adds unasync as a dev dep, and untracks .coverage, which the venv coverage hook rewrote on every run and dirtied the tree. 567 passed, mypy strict clean (83 files), coverage 96.96%, sphinx -W clean. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 77824 -> 0 bytes .gitignore | 5 +- CHANGELOG.md | 34 +++++ docs/api_reference.rst | 13 +- docs/installation.rst | 4 +- docs/user_guide.rst | 29 +++-- glpi_python_client/_errors.py | 45 ++++--- glpi_python_client/auth/_v1_session.py | 54 ++++++-- glpi_python_client/auth/auth.py | 42 ++++-- glpi_python_client/auth/tests/test_auth.py | 42 +++--- .../auth/tests/test_v1_session.py | 29 +++-- glpi_python_client/clients/commons/_config.py | 78 ++++++------ glpi_python_client/clients/commons/_http.py | 120 +++++++++++++++--- .../clients/commons/_transport.py | 98 +++++++------- .../clients/commons/tests/test_http.py | 28 +++- .../commons/tests/test_retry_semantics.py | 72 ++++++++++- .../clients/tests/test_glpi_client.py | 8 +- .../clients/tests/test_raise_site_audit.py | 19 ++- glpi_python_client/testing/utils.py | 9 +- pyproject.toml | 5 +- 20 files changed, 511 insertions(+), 223 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 618897c9ca6a999abb7858f31b5bbec58f55dfc5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77824 zcmeHQ32+=&nSR~VbMzeDx=+g*TasnjIxHWub=W#AlQ?nKhQu2dhKxs2OCDzqWqQW8 zd_>bj7Fa@+vs;8?D-aT(U`au!P+?Lat|EXpu-HHj8_S;k-<#>_ z*5RXSlQ$FUbxHI0bidHKP^B5FT)zeoOi%zCFUIuh7@lwG8P;S#DkFuZ+yR^hfb)<7;dxR8njwCU^|JA&-}qLh!s~R zAc@IXFdT?YdGA)HT1kLxZgJ&sJaYjOQGyc@9KyRg8@M)xaft2dA<;b;_a9*$>9 z8;gcJ;!$rX7$L=ssKIzJ8u2QJmC;088C$Y|t(Y|Z0P@VGfwcDF0`1Gh%;sfc$B4W{ z%xv&%D7V~OxxYWqm#O;d_XoV0!!`!MK_`5$73A?($Vk2j9QC-QZd>-$-yR3WdDU zaZ+V)Y;Q(6V9n+_)1i|Qh~bLnjb;M^(!5$51`8OxBcbTXd|M+Hqbg8Az!&Jj#uJfI zTvb$DhSBIjB^H=a+Ou~vZXw@>1g6s|+A4R{U(IxKCgAVPojSE@o4vBOmP?7mjI!Fw z3i~`0(lxn+8`W=xm4KRv>5k-DPEwCycuBpEWbZ(6+U&N{ zk5~x%VQ~8R-!cy}zG6VWRheo#7zlwknG6JDP-VdfXIcO+4mcKoz=4ryB2MfVe1?`UNN9a3C_4ZLe@MoJkdYKdziOpNtcM zh~8$Xlqyuw1!?MuX76H;rKjIM37p_m!-26(b;n9ErFdw+vM9MsYcw2)EAe1h@!}u^ z3^A-j0o6MiQ{ZN{EhWH!vHc5CWs|hKq1{ed30mcAhfHA-rU<@z@7z2y}#sFjBZ;t^B$8&Z({^#X0 zNd6;yu!Awc7+?%A1{ed30mcAhfHA-rU<@z@7z2!fcO(OD-cl#)kA51B?O20AnD>fZI~X z>C*tb(^4yDX927bf|uXG;Y#F7@*AFM_eJ+X*DJ2uozFN2#qWxn9N%(u*uP|Nvps0N zVhswv7Is@sTGsN9@{QaVI6DgFvbgq%R$C{aRPXj4-(D3qgZKs`c-x$E)83uCb`9>m zZHEu`H4Mjs2NiFdcROt6iYuWI?E8#He8bVvL|BQ$Ro_G~?w^cB4=AJXWpc_t5tq4NhLP+UKNySEmwaa6F8{aU~v$gF`t{>N$rq#dW4zuzdmW~+wc7gNc8vElf$ zj9?onssbQQc>-dxV1GO~dbbkCkfch0w4x9oDS<|fJNp{9*Y zmqvva8LL3``6mNYp=e-?_!Q0!IP6qdScGGLJU&S*!bQy@7GVH?7EvdoG13;=ocRL+ zy#(C7gcm139g#!;LZ?yI8G*{`Tj=8rcPcvoPyJ}F`nKyhYVD~ zn;SZabC7$OiY54As9wAUK8osi_~fM*@YuanPDn4fe&f1B`lu)A{;TuH;z_Z~Im9<{ zUvxZWZ+EP-f0_Th?Gx6t^?e_0k!|E*{32Mo&wQ|Le3` zA=O<_=nTtPHZ7z$I@q$K-TCAH+T((DoNoT6dQk)Kj!6~M$$qinn8yFrTCtF7$`=rm z1smi4DoqwrD+&TqX8d2Nl?bWkLI7k`p9)P7Qm%Yq(#QYhTB(p~$rp-+eZ%bdzf3C^ zQf-9-D%W8x)hdKkN1?znEU9El&=OQ(>Er)m%_gK|s#z~N{x8a}Gd5Hq9}~2rRQWJ9 zNfJeEEE+qQ#{V9T7gBaQEG)v29sj#2TErp@=JCHPpFqsxe<#I8S!ZOa;daiQidvD7 zYM^T-O93)!uS4U6l$AarQysQb>=o!*%8k5p51B?O20AqkLz!+c*Fa{U{?^p&boCSIC z{(nxML-5BA#sFi0F~AsL3@`>51B?O20AqkLz!+c*Fb4hx8L(J9B3}O=7WKb#s+co6sajkJxyJY8_^M}rp&aXO;I3v#Y zIk!0*oV<8Zd{LYczbSrJd{8_rMga&r7z2y}#sFi0F~AsL3=}W}ox=hrj;-d8Y~3k7 z@16DYb1Ff5KK(FSkIvi;U!FgeK6HBK=+QUNo&L_z>CX%H?GUA?nIEbhf|D()c$8d^ zP%`a?uxHZM2pzMb*;4x;4%L(zs2G5g_D22%eE=QrAa1`OnTbp9^mbLIEfWbp}rSR2E6

?L#Fs-EgaGxqjiEw=sge(KNjGHb+zi($mZiK7p6l#_^RX%(9dNtR?1oH^`X^w3L%#^$2xfT` zMDbSg3PPXd&_mx#IySNTmIoZ2IKdJ~P+kXT-fGw%`xW8i$v^Xg)BzWk7xB-aAEJFWB-z^GyCn0T zHjvATd9=pTo4nFr+zMYCB|bTXU_)<_;B^dn*5VMgsquB^k9+2<_BHUes3zsCXn~XV z0Uo_vg+4Twb|G$tf59@q%d6qKx1ZlLk+h&>e?KoZ!-eJB_|HI!=)z^kj#XAp)SO!( zPp^AFO3qAN?8jvrc;wBQ^l#NS<89OV4(b0ii*Q5Q;n?e1X?})rUihSI1*BW$;rH@=n$YNl zFSoe)uP)odHJ^D+KAbN7;kGlEI(jFChmtRH^;#)(g)kuHnq?+4TQt>7(Bf zx72On>uMoP-xj>58a;pFm12(i?0Ny6^!z{BK3*k8M~(E% zFozS%i{bXRbv$xfD$s>x2(^Rm{jY&vyQLPP_uf=r1osCzveCN{@VNoM#3(h&e}@8v?Ca%a<>I0--*zXulmLPuN{>wP?)&4xArk*uuY>)Q$km+ab3L zmw+q*%|g+Dw%HS25rPbfd}8)hg#PevkjYF3LJvbg$N?Ql|N=gW$H z%WQDkBk<2}9$NoEx}R`8HJN*sM`8UozaJJUXU zg1=i%o;%jBoqqWZTrt4UUwUb4@>2Tslj1{m9-Q_?BvG3g7^XQVySC!`b7N2DjDUrIld zH0g+>N+C&+?vn15c1lB1ue4EWlUB)3%TLN*lfNW?PX4s~FY^8JeK5ChP>#y`<$(M? zd57$gZBklpkuFGol3ta5Ex#uJFU&x!!^qje7+?%A1{ed30mcAhfHA-rU<@z@7z6oY z0FP(6)x$(IZzp2a5D`s-L^KW%(a=vs`8Fa}ZY5$x9}(VOBI0s zi-^iiL{w}fqHF^ZrR#|(=_I0h9T7zxM9A$#6t@u}wGvUgmWY})L=3bL(Z8CAZOue% zT}4E16A?X)M07V0;aW+AX9W>%FA;t9L<}z{!dXXzjSTm>Ew$vEwT1{wH4&Sui0G;$ zVp9bX8_S851B?O20AqkL zz!;#x0ABxR<9`}Vvpg6Bi~+^~V}LQh7+?%A1{ed30mcAhfH9Ci2JrrWHvZ2a|14+5 z0AqkLz!+c*Fa{U{i~+^~V}LQh7+?(0U;wZGSM$e^e81%${xAG7*&}@v-tw1lzvABO zI_2tfKJKg%la8~FJ@%*V9kxeorPlj|i$c`$2ksf}UFakYj91Tt{SL3sUkJa$JOAfz zZ}jKZ4+>h8&QG5P67l^${Lwx+wf(rD9jBPB|6r$IRa7+?jX>^0M+EH<&D@P2c^!P~ zeqr^1G8$KXlT-eQ&}7huKm3zZ@%_<=Kl4+!KK&PO1L{;{GR+}crdJlf)T~%j|bGd!FW2g3L(`|Aehu>D3Mj%xXkl@Ck8dxSt?C<$#G}x%-n4h;u3dwBZ`nBpPX7C78M+xc?-G9@?zW8CY8^jzY`1?K z-E3cLd(`?dAuWU~ucBx9?c9HIyW~UCIcePUtY@?PQMcqe?u54@W)AH}2Yvs4^g4H= z2{j%Kn{B9@q5`9lIO#bX7F;tWXbC#CIvgESLeMqE_6rWB_?Vy_rJJ)+Y-w6Zada|= zX$C?%$b(QvGJSG*xhQ(1i4K4)?-|SUtE2msZ~*$gq(|T<=nmta!D`JZq$+ce@=-UU z;V?J}pI?c^qA@(+9oCSLvg9zmt^P%?q4Nj-16r|=qNFGtf$k&btH@8LC4iLvJoG1* zh6G@?<)Pc8gMsl}9C}W{Q8mZSR?RA;Jai3aR5#o4U?>hX99Zeo>_V!TGK_4?Vz2^0 zdh@8B$pB1Wk=8z^o}PU0qN@kG*b#Ly8pF=c#U|n3$&>JJVSh}w4rmC+gA-sHw)yQK zl{P2mLNZg&BeCGv1nwhkq1gIly9oi97SHw(%md}z1VRy1MUvwXIz2dsv`HXW={I0mk@hwM({Y&;X z+k@6C)}ZifVYlU^Wi9_G-^hJ|vqRJyJG9z50i}AW%x^5F-sFFcH4D>xw^p+Z&~+CQ zIzvhK)&e#<9&pJK_OTiPX~(H>qvvm$q0(&CQ2b&l*)KL6lk?DussKn+o`9Gv*jR$l zq)LFaq7WcudJtN11psO;06<3d(JbYF$(1KeN+c1+<5;bv3{bS>3B^^GMzpe00M%9? zpmO~ft-J(abrc9J!;%gcgCk7Pgr(2$YStoPM5dXwY4ie4Fxzp_Y%<`;n=>|4aa00T z9Hq&Jxk<8msA(fNlc|rcCj(QVXn;J#q;YP*VW+~vA{^Q04mF#Lnnf(aV4jE4Y|i`v zfqn~qBqmNgy(9t{6lQ%0)b+7o@m)g}>JSz@oqCL?H_izKp?bploS}m_{J5tU zreX=ET=Dq77JVAYBhn9~HJ(p;Ebgf5CD%^pQ_eQ=UqzSWxc$#|#rCYN%lcXP8G)GP zHe}8v1Z>4SW2m4!47osW6PSO3y_rO4~b!~ zntKyKsiO|%DvvE|u8jbvHm`6@7OmZOBuFGr4SI0XMtp zOslO2WCgXfZ0^I=E-y56rk)%g|GUK}k$gn@y)@$aZ_h6GjJwtKm}{N$31=_dU zF~AsL3@`>51B?O2K>iufEW^;Hr1M0RKFyU|HO%cOOKgV)t0x`j&Cy3*%w;M0ow0Rf#ssg6M3pmvoFe429`K%zUdx-=yG zh@X2afT25qsDm-Ks^xZwX+j^s$Y7AD0dV2Dvo+UQ$X_jt) zL)nQh+A@#d|L1WW%&h;f@_Y=Q{#RV5UAH>F>1+}o6h*kj4#ogufHA-rU<@z@7z1ws z1N8S|FIY#OKb(Nh;euDTyscZ+ueY?_tVu$up3a89#fzkim$p~sb)R|;kFgZll-E7s z=2c|Vvqz12-8^pwWf0Xs@zq0%ElJ<{R-RX8OpDp31@@KnUC{Lw*jH#(LTW?4s{OSV f*uB~^A=O(@$V?0D^@WGdw7|Z+@X(o@R^9&r! httpx.Response: + """Send one raw v1 HTTP call, translating transport faults. + + Every call this session makes goes through here so network failures + surface as :class:`~glpi_python_client.GlpiTransportError` rather than + as the HTTP library's own exception type. That is what lets the retry + predicate above name a library-owned type, and it keeps callers from + having to import the HTTP library to catch a connection failure. + + Raises + ------ + GlpiTransportError + When the request never produced a response. + """ + + try: + return self._http.request(method.upper(), url, **kwargs) + except httpx.HTTPError as exc: + raise transport_error_from(exc, method=method, url=url) from exc + @_RETRY_ON_NETWORK_ERRORS def _init_session(self) -> None: """Acquire one fresh GLPI v1 session token via ``GET /initSession``. @@ -123,7 +151,7 @@ def _init_session(self) -> None: headers["App-Token"] = self._app_token url = f"{self._base_url}/initSession" - response = self._http.get(url, headers=headers, timeout=30) + response = self._dispatch("GET", url, headers=headers, timeout=30) finalize_request_response( response, method="get", @@ -198,7 +226,8 @@ def _renew_session(self) -> None: if self._session_token is not None: try: - self._http.get( + self._dispatch( + "GET", f"{self._base_url}/killSession", headers=self._session_headers(), timeout=10, @@ -225,7 +254,7 @@ def _authenticated_request( success_statuses: tuple[int, ...], headers: dict[str, str] | None = None, **kwargs: Any, - ) -> requests.Response: + ) -> httpx.Response: """Send one authenticated GLPI v1 request and finalize the response. When the GLPI server rejects the current token the helper renews @@ -243,7 +272,7 @@ def _authenticated_request( # per-verb attribute: it is the one call shape both transports share, # and it keeps the verb a value instead of an attribute name. verb = method.upper() - response = self._http.request(verb, url, headers=request_headers, **kwargs) + response = self._dispatch(verb, url, headers=request_headers, **kwargs) if _is_auth_failure_response(response): logger.warning( "GLPI v1 session token was rejected; refreshing session and " @@ -251,7 +280,7 @@ def _authenticated_request( ) self._renew_session() request_headers = {**self._headers(), **(headers or {})} - response = self._http.request(verb, url, headers=request_headers, **kwargs) + response = self._dispatch(verb, url, headers=request_headers, **kwargs) return finalize_request_response( response, method=method, @@ -269,7 +298,8 @@ def close(self) -> None: try: if self._session_token is not None: - self._http.get( + self._dispatch( + "GET", f"{self._base_url}/killSession", headers=self._session_headers(), timeout=10, @@ -309,7 +339,7 @@ def request_json( Resource path appended to the v1 base URL (without leading slash, e.g. ``"PluginFieldsContainer"``). params : dict[str, object] | None, optional - Query-string parameters forwarded to ``requests``. + Query-string parameters forwarded to the HTTP transport. json_body : dict[str, object] | None, optional JSON body serialised into the request when set. The ``Content-Type: application/json`` header is added @@ -346,7 +376,7 @@ def request_json( kwargs["params"] = params headers: dict[str, str] = {} if json_body is not None: - kwargs["data"] = json.dumps(json_body) + kwargs["content"] = json.dumps(json_body) headers["Content-Type"] = "application/json" response = self._authenticated_request( method, @@ -419,7 +449,7 @@ def upload_document( return cast(dict[str, object], payload) -def _is_auth_failure_response(response: requests.Response) -> bool: +def _is_auth_failure_response(response: httpx.Response) -> bool: """Return whether one GLPI v1 response means the session token is invalid. Both HTTP-level rejection and the ``ERROR_SESSION_TOKEN_INVALID`` payload diff --git a/glpi_python_client/auth/auth.py b/glpi_python_client/auth/auth.py index 3e3bf1e..80491ba 100644 --- a/glpi_python_client/auth/auth.py +++ b/glpi_python_client/auth/auth.py @@ -10,14 +10,17 @@ import logging from datetime import datetime, timedelta, timezone -import requests +import httpx from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed from glpi_python_client._errors import ( GlpiServerError, + GlpiTransportError, GlpiValidationError, status_error_class, ) +from glpi_python_client.clients.commons._config import build_http_session +from glpi_python_client.clients.commons._http import transport_error_from logger = logging.getLogger(__name__) @@ -40,8 +43,8 @@ class GLPITokenManager: password : str | None, optional Password for the password grant flow. Provide it together with ``username``. - session : requests.Session | None, optional - Existing session to reuse. + session : httpx.Client | None, optional + Existing HTTP client to reuse. auth_token_refresh : int | None, optional Maximum token age in seconds before a refresh is attempted. ``None`` disables interval-based refreshes. @@ -54,7 +57,7 @@ def __init__( client_secret: str | None = None, username: str | None = None, password: str | None = None, - session: requests.Session | None = None, + session: httpx.Client | None = None, auth_token_refresh: int | None = None, ) -> None: self._token_url = token_url @@ -63,7 +66,7 @@ def __init__( self._username = username self._password = password self._owns_session = session is None - self._session = session or requests.Session() + self._session = session or build_http_session(verify_ssl=True) self._auth_token_refresh_interval = _refresh_interval(auth_token_refresh) self._validate_credentials() @@ -73,6 +76,25 @@ def __init__( self.token_expires_at: datetime | None = None self.token_updated_at: datetime | None = None + def _post_token_request(self, data: dict[str, str]) -> httpx.Response: + """POST the OAuth token endpoint, translating transport faults. + + Network failures surface as + :class:`~glpi_python_client.GlpiTransportError` so the retry + predicates below can name a library-owned type and callers never have + to import the HTTP library to catch a connection failure. + + Raises + ------ + GlpiTransportError + When the token request never produced a response. + """ + + try: + return self._session.post(self._token_url, data=data, timeout=30) + except httpx.HTTPError as exc: + raise transport_error_from(exc, method="post", url=self._token_url) from exc + @property def auth_token_refresh(self) -> int | None: """Return the proactive refresh delay configured for this manager. @@ -230,7 +252,7 @@ def _should_refresh_by_interval(self, now: datetime) -> bool: return now >= self.token_updated_at + self._auth_token_refresh_interval @retry( - retry=retry_if_exception_type((requests.RequestException, GlpiServerError)), + retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)), stop=stop_after_attempt(3), wait=wait_fixed(3), reraise=True, @@ -255,7 +277,7 @@ def _acquire_token(self) -> None: """ data = self._build_token_request_data() - response = self._session.post(self._token_url, data=data, timeout=30) + response = self._post_token_request(data) if 200 <= response.status_code < 300: self._store_token_data(response.json()) return @@ -272,7 +294,7 @@ def _acquire_token(self) -> None: ) @retry( - retry=retry_if_exception_type(requests.RequestException), + retry=retry_if_exception_type(GlpiTransportError), stop=stop_after_attempt(3), wait=wait_fixed(3), reraise=True, @@ -297,7 +319,7 @@ def _refresh_access_token(self) -> None: GlpiServerError If the token endpoint fails (5xx) while refreshing. This method's own retry decorator only matches - ``requests.RequestException`` (network-level faults), not + ``GlpiTransportError`` (network-level faults), not ``GlpiServerError``, so it does not retry the fall-through to :meth:`_acquire_token`. The nested call carries its own independent decorator, which does retry ``GlpiServerError`` up @@ -324,7 +346,7 @@ def _refresh_access_token(self) -> None: assert self._client_secret is not None data["client_id"] = self._client_id data["client_secret"] = self._client_secret - response = self._session.post(self._token_url, data=data, timeout=30) + response = self._post_token_request(data) if 200 <= response.status_code < 300: self._store_token_data(response.json(), label="refreshed") return diff --git a/glpi_python_client/auth/tests/test_auth.py b/glpi_python_client/auth/tests/test_auth.py index 99e1fb9..a4b62af 100644 --- a/glpi_python_client/auth/tests/test_auth.py +++ b/glpi_python_client/auth/tests/test_auth.py @@ -3,11 +3,16 @@ from datetime import datetime, timedelta, timezone from typing import cast +import httpx import pytest -import requests from tenacity import wait_fixed -from glpi_python_client import GlpiAuthError, GlpiServerError, GlpiValidationError +from glpi_python_client import ( + GlpiAuthError, + GlpiServerError, + GlpiTransportError, + GlpiValidationError, +) from glpi_python_client.auth.auth import GLPITokenManager from glpi_python_client.testing.utils import FakeResponse, TokenResponse @@ -33,7 +38,7 @@ def test_token_manager_uses_password_grant_with_user_credentials_only() -> None: token_url="https://glpi.example.test/api.php/token", username="api-user", password="api-password", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) auth._acquire_token() @@ -53,7 +58,7 @@ def test_token_manager_uses_client_credentials_grant() -> None: token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="client-secret", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) auth._acquire_token() @@ -73,7 +78,7 @@ def test_token_manager_preserves_raw_credential_text() -> None: token_url="https://glpi.example.test/api.php/token", client_id=" client-id ", client_secret=" client-secret ", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) auth._acquire_token() @@ -94,7 +99,7 @@ def test_token_manager_uses_password_grant_with_both_credential_sets() -> None: client_secret="client-secret", username="api-user", password="api-password", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) auth._acquire_token() @@ -115,7 +120,7 @@ def test_token_manager_refreshes_when_configured_interval_elapses() -> None: token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="client-secret", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), auth_token_refresh=60, ) auth.access_token = "old-token" @@ -211,7 +216,7 @@ def test_oauth_401_raises_glpi_auth_error() -> None: token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="wrong", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) with pytest.raises(GlpiAuthError) as excinfo: manager.ensure_token() @@ -230,7 +235,7 @@ def test_oauth_401_is_not_retried() -> None: token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="wrong", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) with pytest.raises(GlpiAuthError): manager.ensure_token() @@ -249,7 +254,7 @@ def test_oauth_5xx_raises_glpi_server_error_after_retries( token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="client-secret", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) with pytest.raises(GlpiServerError) as excinfo: manager.ensure_token() @@ -273,7 +278,7 @@ def _make_refresh_ready_manager( token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="client-secret", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) manager.access_token = "stale-token" manager.refresh_token = "refresh-token" @@ -324,7 +329,7 @@ def test_refresh_5xx_persistent_costs_one_refresh_plus_nested_acquire_attempts( call on any non-2xx response instead of raising directly (auth.py:327-332). That nested call is independently decorated with ``stop_after_attempt(3)`` and retries ``GlpiServerError``. This method's - own decorator only matches ``requests.RequestException`` (a genuine + own decorator only matches ``httpx.HTTPError`` (a genuine network fault on the refresh POST itself), not ``GlpiServerError``, so it does not retry the fall-through a second time on top of the nested call's own retries. @@ -369,17 +374,17 @@ def __init__(self) -> None: def post(self, url: str, data: dict[str, str], timeout: int) -> FakeResponse: self.calls.append({"url": url, "data": data, "timeout": timeout}) - raise requests.ConnectionError("network down") + raise httpx.ConnectError("network down") session = _FailingSession() manager = GLPITokenManager( token_url="https://glpi.example.test/api.php/token", client_id="client-id", client_secret="client-secret", - session=cast(requests.Session, session), + session=cast(httpx.Client, session), ) - with pytest.raises(requests.ConnectionError): + with pytest.raises(GlpiTransportError): manager.ensure_token() assert len(session.calls) == 3 @@ -396,7 +401,8 @@ def test_refresh_network_error_is_retried_three_times( ``test_refresh_5xx_persistent_costs_one_refresh_plus_nested_acquire_attempts`` above, and ``_acquire_token``'s network retry is pinned by ``test_acquire_token_network_error_is_retried_three_times``. A - ``requests.ConnectionError`` raised by ``session.post`` propagates + ``httpx.ConnectError`` raised by ``session.post`` is translated to + ``GlpiTransportError`` and propagates *before* ``_refresh_access_token`` reaches its non-2xx fallthrough branch (auth.py:327-332), so the nested ``_acquire_token`` call is never reached here -- unlike the persistent-5xx case, this pins @@ -416,12 +422,12 @@ def __init__(self) -> None: def post(self, url: str, data: dict[str, str], timeout: int) -> FakeResponse: self.calls.append({"url": url, "data": data, "timeout": timeout}) - raise requests.ConnectionError("network down") + raise httpx.ConnectError("network down") session = _FailingSession() manager = _make_refresh_ready_manager(cast(_FakeSession, session)) - with pytest.raises(requests.ConnectionError): + with pytest.raises(GlpiTransportError): manager.ensure_token() assert len(session.calls) == 3 diff --git a/glpi_python_client/auth/tests/test_v1_session.py b/glpi_python_client/auth/tests/test_v1_session.py index 98cacf9..b51d00e 100644 --- a/glpi_python_client/auth/tests/test_v1_session.py +++ b/glpi_python_client/auth/tests/test_v1_session.py @@ -5,10 +5,15 @@ import json as jsonlib from typing import Any, cast +import httpx import pytest -import requests -from glpi_python_client import GlpiProtocolError, GlpiServerError, GlpiValidationError +from glpi_python_client import ( + GlpiProtocolError, + GlpiServerError, + GlpiTransportError, + GlpiValidationError, +) from glpi_python_client.auth._v1_session import GLPIV1Session from glpi_python_client.testing.utils import FakeResponse @@ -21,7 +26,7 @@ def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: class _FakeV1Http: - """In-memory ``requests.Session`` stand-in capturing every call.""" + """In-memory ``httpx.Client`` stand-in capturing every call.""" def __init__(self, responses: dict[str, list[FakeResponse]]) -> None: self._responses = responses @@ -44,7 +49,7 @@ def request( ``GLPIV1Session`` routes authenticated calls through ``session.request(method, ...)`` rather than a per-verb attribute, - because that is the one call shape ``requests`` and ``httpx`` share. + because that is the one call shape every transport agrees on. This dispatches back to the per-verb handlers so their recorded call shapes stay identical. """ @@ -153,7 +158,7 @@ def _make(http: _FakeV1Http) -> GLPIV1Session: app_token="app-token", verify_ssl=True, ) - session._http = cast(requests.Session, http) # type: ignore[assignment] + session._http = cast(httpx.Client, http) # type: ignore[assignment] return session @@ -373,7 +378,7 @@ def test_v1_close_tolerates_kill_failure() -> None: class _BoomHttp(_FakeV1Http): def get(self, url: str, headers: dict[str, str], timeout: int) -> FakeResponse: if url.endswith("/killSession"): - raise requests.RequestException("boom") + raise httpx.RequestError("boom") return super().get(url, headers, timeout) http = _BoomHttp( @@ -406,7 +411,7 @@ def test_request_json_sends_body_and_returns_parsed_payload() -> None: assert result == {"ok": True} post_call = next(call for call in http.calls if call["method"] == "POST") assert post_call["url"].endswith("/PluginFieldsContainer") - assert post_call["data"] == jsonlib.dumps({"input": {"name": "x"}}) + assert post_call["content"] == jsonlib.dumps({"input": {"name": "x"}}) assert post_call["headers"]["Content-Type"] == "application/json" @@ -475,11 +480,11 @@ def test_request_json_retries_on_5xx() -> None: def test_request_json_retries_on_network_error() -> None: """Network faults during ``request_json`` are retried 3x, not swallowed. - Pins the ``requests.RequestException`` member of the v1 retry predicate + Pins the ``GlpiTransportError`` member of the v1 retry predicate (``_RETRY_ON_NETWORK_ERRORS`` in ``_v1_session.py``): the 5xx tests above only exercise the ``GlpiServerError`` member. Without this test a future - edit that narrows the predicate to drop ``requests.RequestException`` - (for example when plan 3 swaps in ``GlpiTransportError``) would silently + edit that narrows the predicate to drop ``GlpiTransportError`` + would silently drop v1 network retries from 3 attempts to 1 while every committed test stayed green. """ @@ -502,7 +507,7 @@ def get( **kwargs, } ) - raise requests.ConnectionError("network down") + raise httpx.ConnectError("network down") return super().get(url, headers, timeout, **kwargs) http = _FlakyHttp( @@ -511,7 +516,7 @@ def get( } ) session = _make(http) - with pytest.raises(requests.ConnectionError): + with pytest.raises(GlpiTransportError): session.request_json("GET", "PluginFieldsContainer") json_calls = [c for c in http.calls if c["url"].endswith("/PluginFieldsContainer")] assert len(json_calls) == 3 diff --git a/glpi_python_client/clients/commons/_config.py b/glpi_python_client/clients/commons/_config.py index ef33a7e..851a083 100644 --- a/glpi_python_client/clients/commons/_config.py +++ b/glpi_python_client/clients/commons/_config.py @@ -12,8 +12,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Protocol -import requests -import urllib3 +import httpx from glpi_python_client._errors import GlpiValidationError @@ -21,6 +20,14 @@ from glpi_python_client.auth._v1_session import GLPIV1Session from glpi_python_client.auth.auth import GLPITokenManager +#: Request timeout applied to every call that does not override it. +#: +#: ``httpx`` defaults to 5 seconds where ``requests`` defaults to no timeout +#: at all, so leaving this unset would silently start cutting off the slow +#: GLPI searches that used to be allowed to finish. 30s matches the value the +#: transport has always passed explicitly. +DEFAULT_TIMEOUT_SECONDS = 30.0 + class SessionFactory(Protocol): """Callable that builds the transport session for a client. @@ -31,7 +38,7 @@ class SessionFactory(Protocol): and a positional-argument factory would let that guarantee slip. """ - def __call__(self, *, verify_ssl: bool) -> requests.Session: + def __call__(self, *, verify_ssl: bool) -> httpx.Client: """Return a session configured for ``verify_ssl``.""" @@ -44,40 +51,33 @@ class ClientResources: """ glpi_api_url: str - session: requests.Session + session: httpx.Client auth: GLPITokenManager v1: GLPIV1Session | None -def configure_ssl_warning_policy(*, verify_ssl: bool) -> None: - """Adjust insecure-request warning behaviour for the configured SSL policy. - - When certificate verification is disabled, urllib3 warnings are muted so - callers do not get repeated noise from every request made by the - client. - """ - - if verify_ssl: - return - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - -def build_http_session(*, verify_ssl: bool) -> requests.Session: - """Construct the HTTP session used for every GLPI call. +def build_http_session(*, verify_ssl: bool) -> httpx.Client: + """Construct the HTTP client used for every GLPI call. This is the single place the library instantiates a transport session, - which makes it the seam the transport swap turns on. Two properties - matter and both are the reason this is a function rather than two inline - lines: - - * ``verify`` is applied **as part of construction**. ``requests`` - tolerates assigning it afterwards; ``httpx`` does not — it reads - ``verify`` only in ``Client.__init__`` and a later assignment is - accepted and silently ignored, leaving certificate verification on - when the caller asked for it off. - * Callers that need to intercept traffic (tests, and anything wanting - ``httpx.MockTransport``) can substitute this factory instead of - monkey-patching a session after the fact. + which is what makes the transport swappable at all. Three settings are + applied here deliberately, because each one differs between ``httpx`` + and the ``requests`` transport this replaced: + + * ``verify`` is applied **as part of construction**. ``httpx`` reads it + only in ``Client.__init__``; a later assignment is accepted and + silently ignored, which would leave certificate verification on when + the caller asked for it off. + * ``follow_redirects`` is enabled to preserve the previous behaviour. + ``requests`` follows redirects by default and ``httpx`` does not, so + omitting this would silently turn a followed redirect into a bare 3xx + response handed back to the caller. + * ``timeout`` is pinned to :data:`DEFAULT_TIMEOUT_SECONDS` rather than + left at the ``httpx`` default of 5 seconds. + + Callers that need to intercept traffic (tests, and anything wanting + ``httpx.MockTransport``) can substitute this factory instead of + monkey-patching a session after the fact. Parameters ---------- @@ -86,13 +86,15 @@ def build_http_session(*, verify_ssl: bool) -> requests.Session: Returns ------- - requests.Session - A session configured for the requested SSL policy. + httpx.Client + A client configured for the requested SSL policy. """ - session = requests.Session() - session.verify = verify_ssl - return session + return httpx.Client( + verify=verify_ssl, + follow_redirects=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) def build_client_resources( @@ -136,7 +138,6 @@ def build_client_resources( v1_base_url=v1_base_url, v1_user_token=v1_user_token, ) - configure_ssl_warning_policy(verify_ssl=verify_ssl) factory = session_factory or build_http_session session = factory(verify_ssl=verify_ssl) @@ -293,10 +294,11 @@ def validate_v1_document_config( __all__ = [ + "DEFAULT_TIMEOUT_SECONDS", "ClientResources", "build_client_env_config", "build_client_resources", - "configure_ssl_warning_policy", + "build_http_session", "normalize_client_api_url", "parse_optional_env_bool", "parse_optional_env_int", diff --git a/glpi_python_client/clients/commons/_http.py b/glpi_python_client/clients/commons/_http.py index 8d2c84d..9adde2c 100644 --- a/glpi_python_client/clients/commons/_http.py +++ b/glpi_python_client/clients/commons/_http.py @@ -10,27 +10,78 @@ import logging from collections.abc import Mapping -import requests +import httpx from glpi_python_client._errors import ( GlpiProtocolError, GlpiServerError, + GlpiTimeoutError, + GlpiTransportError, status_error_class, ) from glpi_python_client.clients.commons._constants import RequestParamValue -def response_reason(response: requests.Response) -> str: +def transport_error_from( + exc: httpx.HTTPError, + *, + method: str, + url: str, +) -> GlpiTransportError: + """Map one transport-level failure onto the library's public error type. + + Network faults are the last part of the failure surface that still + escaped as third-party exceptions. Translating them here means callers + catch :class:`~glpi_python_client.GlpiError` and never have to import the + HTTP library, which is what :class:`~glpi_python_client.GlpiTransportError` + was reserved for. + + It also removes a whole class of silent breakage. Retry predicates used + to name the HTTP library's own exception base; because those trees are + completely disjoint between libraries, swapping the transport without + editing every predicate made retries stop matching — silently, with no + error and a green test suite. Predicates now name this library-owned type + instead, so a future transport change cannot invalidate them. + + Parameters + ---------- + exc : httpx.HTTPError + The transport failure to translate. + method : str + HTTP verb, used only to build the message. + url : str + Absolute URL of the failed request, used only to build the message. + + Returns + ------- + GlpiTransportError + :class:`~glpi_python_client.GlpiTimeoutError` when the failure was a + timeout, otherwise :class:`~glpi_python_client.GlpiTransportError`. + The original exception should be attached with ``raise ... from exc`` + by the caller. + """ + + error_class = ( + GlpiTimeoutError + if isinstance(exc, httpx.TimeoutException) + else GlpiTransportError + ) + return error_class( + f"GLPI {method.upper()} {url} failed: {type(exc).__name__}: {exc}" + ) + + +def response_reason(response: httpx.Response) -> str: """Return one response's HTTP reason phrase, whatever the transport. - ``requests`` spells this ``Response.reason``; ``httpx`` spells it - ``Response.reason_phrase`` and has no ``reason`` attribute at all. Every - read of the phrase goes through this helper so swapping the transport - touches one function instead of every message that quotes it. + ``httpx`` spells this ``Response.reason_phrase``; ``requests`` spelled it + ``Response.reason``. Both spellings are probed so the helper keeps + working for the duck-typed response fakes in downstream test suites, + which were written against the older attribute name. Parameters ---------- - response : requests.Response + response : httpx.Response Response to read the reason phrase from. Typed against the current transport; any object exposing either attribute works at runtime. @@ -49,26 +100,52 @@ def response_reason(response: requests.Response) -> str: def request_params( params: dict[str, object] | None, ) -> dict[str, RequestParamValue] | None: - """Normalise query parameters into ``requests``-compatible values. + """Normalise query parameters into transport-compatible values. Each value is converted through :func:`request_param_value` so callers can pass richer Python objects without repeating serialisation logic. + + Keys whose value is ``None`` are **dropped** rather than forwarded. This + is deliberate and load-bearing: ``requests`` omitted such keys from the + query string entirely, whereas ``httpx`` encodes them as a valueless + ``key=``. Sending an empty value to GLPI is not a no-op — an empty filter + or search value is interpreted as "match everything", so forwarding the + key would silently widen a query instead of leaving it unconstrained. + Normalising here keeps the emitted query string identical across + transports. """ if params is None: return None - return {key: request_param_value(value) for key, value in params.items()} + return { + key: request_param_value(value) + for key, value in params.items() + if value is not None + } def request_param_value(value: object) -> RequestParamValue: - """Normalise one query parameter value for ``requests``. - - Native scalar values are preserved and any other object is stringified - so higher-level client code can pass enums and identifiers without - special handling. + """Normalise one query parameter value for the HTTP transport. + + Values are rendered exactly as the previous ``requests``-based transport + rendered them, so the wire format does not depend on which HTTP library + is installed. Two conversions exist only to preserve that: + + * ``bytes`` are decoded to text. ``httpx`` would otherwise stringify the + object itself and emit the Python repr (``b'x'``) rather than its + contents. + * ``bool`` is rendered ``"True"``/``"False"``. ``httpx`` renders booleans + lowercase; ``requests`` did not. This is checked before ``int`` + because ``bool`` is a subclass of ``int``. """ - if value is None or isinstance(value, str | int | float | bytes): + if value is None or isinstance(value, str): + return value + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if isinstance(value, bool): + return str(value) + if isinstance(value, int | float): return value return str(value) @@ -136,13 +213,13 @@ def build_request_url(glpi_api_url: str, endpoint: str) -> str: def finalize_request_response( - response: requests.Response, + response: httpx.Response, *, method: str, url: str, success_statuses: tuple[int, ...], logger: logging.Logger, -) -> requests.Response: +) -> httpx.Response: """Validate one GLPI transport response and preserve warning behaviour. Server errors are raised immediately while non-success statuses outside @@ -180,7 +257,7 @@ def finalize_request_response( def ensure_response_status( - response: requests.Response, + response: httpx.Response, *, success_statuses: tuple[int, ...], failure_message: str, @@ -208,7 +285,7 @@ def ensure_response_status( ) -def response_json_or_empty(response: requests.Response) -> object: +def response_json_or_empty(response: httpx.Response) -> object: """Return the parsed JSON body or an empty mapping for empty responses. Unlike :func:`response_json_mapping` this helper preserves list and @@ -221,7 +298,7 @@ def response_json_or_empty(response: requests.Response) -> object: return response.json() -def response_json_mapping(response: requests.Response) -> Mapping[str, object]: +def response_json_mapping(response: httpx.Response) -> Mapping[str, object]: """Return the JSON response payload as a mapping when possible. Empty response bodies become an empty mapping and non-mapping JSON @@ -234,7 +311,7 @@ def response_json_mapping(response: requests.Response) -> Mapping[str, object]: def require_response_int( - response: requests.Response, + response: httpx.Response, *, keys: tuple[str, ...], missing_message: str, @@ -312,5 +389,6 @@ def unwrap_timeline_items(payload: object) -> list[dict[str, object]]: "require_response_int", "response_json_mapping", "response_json_or_empty", + "transport_error_from", "unwrap_timeline_items", ] diff --git a/glpi_python_client/clients/commons/_transport.py b/glpi_python_client/clients/commons/_transport.py index 60737f8..ae1d9db 100644 --- a/glpi_python_client/clients/commons/_transport.py +++ b/glpi_python_client/clients/commons/_transport.py @@ -8,7 +8,7 @@ Concurrency model ----------------- The transport is intentionally synchronous and backed by the blocking -``requests`` library. Access to the auth token manager is serialised with +``httpx.Client``. Access to the auth token manager is serialised with a :class:`threading.Lock` rather than an :class:`asyncio.Lock` because: * the sync :class:`~glpi_python_client.clients.GlpiClient` can be shared @@ -23,8 +23,8 @@ The lock is held only for the short critical section that refreshes the token; HTTP calls themselves run without the lock so concurrent requests can proceed in parallel while sharing the same access token. The -underlying :class:`requests.Session` connection pool is thread-safe for -concurrent HTTP calls; the session is built once at construction time +underlying :class:`httpx.Client` connection pool is thread-safe for +concurrent HTTP calls; the client is built once at construction time and is never mutated afterwards. """ @@ -35,10 +35,10 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeVar -import requests +import httpx from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed -from glpi_python_client._errors import GlpiServerError +from glpi_python_client._errors import GlpiServerError, GlpiTransportError from glpi_python_client.clients.commons._http import ( build_request_headers, build_request_url, @@ -48,6 +48,7 @@ request_params, require_access_token, require_response_int, + transport_error_from, unwrap_timeline_items, ) from glpi_python_client.clients.commons._payloads import ( @@ -64,6 +65,25 @@ ModelT = TypeVar("ModelT", bound=GlpiModel) +#: Shared retry policy for every v2 transport verb. +#: +#: Declared once rather than repeated on each of the four verb helpers, and +#: expressed entirely in library-owned exception types. Both parts are +#: deliberate. A predicate that names the HTTP library's own exception base +#: stops matching the moment the transport is swapped — the exception trees of +#: the different libraries are completely disjoint — and retries then vanish +#: with no error, no warning and a green test suite. Naming +#: :class:`~glpi_python_client.GlpiTransportError`, which +#: :func:`~glpi_python_client.clients.commons._http.transport_error_from` +#: guarantees every network fault is translated into, makes that failure +#: impossible to reintroduce. +_RETRY_ON_NETWORK_ERRORS = retry( + retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)), + stop=stop_after_attempt(3), + wait=wait_fixed(3), + reraise=True, +) + class TransportMixin: """Synchronous GLPI API transport helpers shared by the API mixins. @@ -80,14 +100,14 @@ class TransportMixin: the sync client directly or by the async client through :func:`asyncio.to_thread` — never race while updating shared authentication state. HTTP dispatch runs outside the lock and relies - on the thread-safety of :class:`requests.Session` for concurrent + on the thread-safety of :class:`httpx.Client` for concurrent calls. """ _auth: GLPITokenManager _auth_lock: threading.Lock _closed: bool = False - _session: requests.Session + _session: httpx.Client _v1: GLPIV1Session | None entity_recursive: bool glpi_api_url: str @@ -151,20 +171,32 @@ def _send_request( method: str, url: str, **kwargs: Any, - ) -> requests.Response: + ) -> httpx.Response: """Dispatch one blocking HTTP call. The helper exists as an indirection seam so tests can stub HTTP dispatch without monkey-patching the session attribute directly. Dispatch goes through ``session.request(method, url, ...)`` rather - than looking up a per-verb attribute. ``request`` is the one call - shape ``requests`` and ``httpx`` agree on, and keeping the verb a - value rather than an attribute name means the transport swap does - not have to reason about dynamic attribute lookup. + than looking up a per-verb attribute, keeping the verb a value + instead of an attribute name. + + Transport-level failures are translated into + :class:`~glpi_python_client.GlpiTransportError` (or + :class:`~glpi_python_client.GlpiTimeoutError`) here, at the single + point where the HTTP library is actually called, so no third-party + exception escapes into the caller's ``except`` clauses. + + Raises + ------ + GlpiTransportError + When the request never produced a response. """ - return self._session.request(method.upper(), url, **kwargs) + try: + return self._session.request(method.upper(), url, **kwargs) + except httpx.HTTPError as exc: + raise transport_error_from(exc, method=method, url=url) from exc def _execute_request( self, @@ -176,7 +208,7 @@ def _execute_request( json_body: dict[str, object] | None = None, skip_entity: bool = False, include_content_type: bool = False, - ) -> requests.Response: + ) -> httpx.Response: """Execute one authenticated GLPI request. The helper normalises the endpoint URL, headers, timeout, and @@ -215,21 +247,16 @@ def _execute_request( logger=logger, ) - @retry( - retry=retry_if_exception_type((requests.RequestException, GlpiServerError)), - stop=stop_after_attempt(3), - wait=wait_fixed(3), - reraise=True, - ) + @_RETRY_ON_NETWORK_ERRORS def _get_request( self, endpoint: str, params: dict[str, object] | None = None, skip_entity: bool = False, - ) -> requests.Response: + ) -> httpx.Response: """Execute one authenticated GLPI ``GET`` request. - Network errors (:class:`requests.RequestException`) and 5xx + Network errors (:class:`~glpi_python_client.GlpiTransportError`) and 5xx responses (:class:`~glpi_python_client.GlpiServerError`) are retried up to 3 times, with ``reraise=True`` so the real error propagates once retries are exhausted; 4xx responses are @@ -244,18 +271,13 @@ def _get_request( skip_entity=skip_entity, ) - @retry( - retry=retry_if_exception_type((requests.RequestException, GlpiServerError)), - stop=stop_after_attempt(3), - wait=wait_fixed(3), - reraise=True, - ) + @_RETRY_ON_NETWORK_ERRORS def _post_request( self, endpoint: str, json_body: dict[str, object] | None = None, skip_entity: bool = False, - ) -> requests.Response: + ) -> httpx.Response: """Execute one authenticated GLPI ``POST`` request. JSON request bodies automatically include the content-type header @@ -271,17 +293,12 @@ def _post_request( include_content_type=True, ) - @retry( - retry=retry_if_exception_type((requests.RequestException, GlpiServerError)), - stop=stop_after_attempt(3), - wait=wait_fixed(3), - reraise=True, - ) + @_RETRY_ON_NETWORK_ERRORS def _update_request( self, endpoint: str, json_body: dict[str, object] | None = None, - ) -> requests.Response: + ) -> httpx.Response: """Execute one authenticated GLPI ``PATCH`` request. The helper uses the same authenticated execution path as the @@ -297,18 +314,13 @@ def _update_request( include_content_type=True, ) - @retry( - retry=retry_if_exception_type((requests.RequestException, GlpiServerError)), - stop=stop_after_attempt(3), - wait=wait_fixed(3), - reraise=True, - ) + @_RETRY_ON_NETWORK_ERRORS def _delete_request( self, endpoint: str, json_body: dict[str, object] | None = None, skip_entity: bool = False, - ) -> requests.Response: + ) -> httpx.Response: """Execute one authenticated GLPI ``DELETE`` request. Some delete endpoints accept a JSON body, so the content-type diff --git a/glpi_python_client/clients/commons/tests/test_http.py b/glpi_python_client/clients/commons/tests/test_http.py index 337f95f..bd1f6e1 100644 --- a/glpi_python_client/clients/commons/tests/test_http.py +++ b/glpi_python_client/clients/commons/tests/test_http.py @@ -26,19 +26,39 @@ def test_request_param_value_normalises_supported_types() -> None: - """The helper returns native scalar values unchanged and stringifies others.""" + """The helper renders every value the way the wire format expects. + + Numbers and strings pass through untouched. ``bool`` and ``bytes`` are + converted deliberately, because the underlying HTTP libraries disagree + about them: ``httpx`` renders booleans lowercase and stringifies a + ``bytes`` object into its Python repr (``b'x'``), where the previous + ``requests``-based transport emitted ``True`` and the decoded text. + Normalising here keeps the emitted query string identical regardless of + which library is installed. + """ - assert request_param_value(True) is True assert request_param_value(7) == 7 + assert request_param_value(1.5) == 1.5 assert request_param_value("hello") == "hello" + assert request_param_value(True) == "True" + assert request_param_value(False) == "False" + assert request_param_value(b"raw") == "raw" assert request_param_value(object()) != "" def test_request_params_drops_none_values() -> None: - """``None`` parameter values are excluded from the produced query mapping.""" + """``None`` parameter values are excluded from the produced query mapping. + + This is a correctness guarantee, not a tidiness one. ``requests`` omitted + a ``None``-valued key from the query string entirely; ``httpx`` encodes it + as a valueless ``key=``. GLPI does not treat those the same — an empty + filter or search value matches *everything* — so forwarding the key would + silently widen a query rather than leave it unconstrained. + """ cleaned = request_params({"limit": 10, "filter": None, "force": True}) - assert cleaned == {"limit": 10, "filter": None, "force": True} + assert cleaned == {"limit": 10, "force": "True"} + assert "filter" not in cleaned def test_build_request_url_concatenates_base_and_endpoint() -> None: diff --git a/glpi_python_client/clients/commons/tests/test_retry_semantics.py b/glpi_python_client/clients/commons/tests/test_retry_semantics.py index d628f0b..c8c9d97 100644 --- a/glpi_python_client/clients/commons/tests/test_retry_semantics.py +++ b/glpi_python_client/clients/commons/tests/test_retry_semantics.py @@ -10,11 +10,18 @@ from collections.abc import Iterator from typing import Any +import httpx import pytest -import requests from tenacity import wait_fixed -from glpi_python_client import GlpiClient, GlpiNotFoundError, GlpiServerError +from glpi_python_client import ( + GlpiClient, + GlpiError, + GlpiNotFoundError, + GlpiServerError, + GlpiTimeoutError, + GlpiTransportError, +) from glpi_python_client.clients.commons._http import ensure_response_status from glpi_python_client.testing.utils import FakeResponse, make_client @@ -144,7 +151,16 @@ def test_tolerant_search_still_returns_empty_on_4xx(client: Any) -> None: @pytest.mark.parametrize("method_name", _RETRIED_METHODS) def test_network_errors_are_still_retried(client: Any, method_name: str) -> None: - """Real ``requests`` transport faults keep their retry behaviour. + """Real transport faults are translated and still retried three times. + + The fault is injected at ``session.request`` — *below* the translation + boundary — rather than by stubbing ``_send_request``. That matters: a stub + above the boundary would raise the HTTP library's own exception, which the + retry predicate no longer names, so the test would pass or fail for + reasons unrelated to the behaviour it is meant to pin. Injecting here + exercises the real path end to end: a genuine ``httpx`` fault, translated + into ``GlpiTransportError``, matched by the predicate, retried three + times, and surfaced to the caller as a library error. Parametrized across all four retried verbs so the network-fault attempt count is pinned for each, not just ``_get_request``. @@ -152,12 +168,54 @@ def test_network_errors_are_still_retried(client: Any, method_name: str) -> None attempts: list[int] = [] - def _send(method: str, url: str, **kw: Any) -> FakeResponse: + def _request(method: str, url: str, **kw: Any) -> FakeResponse: attempts.append(1) - raise requests.ConnectionError("network down") + raise httpx.ConnectError("network down") - client._send_request = _send - with pytest.raises(requests.ConnectionError): + client._session.request = _request + with pytest.raises(GlpiTransportError): getattr(client, method_name)("Assistance/Ticket") assert len(attempts) == 3 + + +@pytest.mark.parametrize("method_name", _RETRIED_METHODS) +def test_no_third_party_exception_reaches_the_caller( + client: Any, method_name: str +) -> None: + """A network fault never surfaces as the HTTP library's own exception. + + The public contract is that ``GlpiError`` is sufficient to catch the + library's failures. This pins the half of that promise which used to be + false: transport faults escaped as third-party exceptions, forcing callers + to import the HTTP library. If the translation is ever removed, the raw + exception reaches the caller and this fails. + """ + + def _request(method: str, url: str, **kw: Any) -> FakeResponse: + raise httpx.ConnectError("network down") + + client._session.request = _request + with pytest.raises(GlpiError) as excinfo: + getattr(client, method_name)("Assistance/Ticket") + + assert not isinstance(excinfo.value, httpx.HTTPError) + # The original fault stays reachable for debugging. + assert isinstance(excinfo.value.__cause__, httpx.ConnectError) + + +def test_timeouts_narrow_to_the_timeout_subclass(client: Any) -> None: + """A timeout surfaces as ``GlpiTimeoutError``, not just the base class. + + ``GlpiTimeoutError`` exists so callers can single out the "GLPI was too + slow" case from "GLPI was unreachable". That only works if the translation + actually inspects the fault type rather than flattening everything to the + base class. + """ + + def _request(method: str, url: str, **kw: Any) -> FakeResponse: + raise httpx.ConnectTimeout("too slow") + + client._session.request = _request + with pytest.raises(GlpiTimeoutError): + client._get_request("Assistance/Ticket") diff --git a/glpi_python_client/clients/tests/test_glpi_client.py b/glpi_python_client/clients/tests/test_glpi_client.py index f1114ca..cc1dad0 100644 --- a/glpi_python_client/clients/tests/test_glpi_client.py +++ b/glpi_python_client/clients/tests/test_glpi_client.py @@ -274,15 +274,15 @@ def test_glpi_client_init_failure_closes_session( closed: dict[str, Any] = {} - import requests + import httpx - original_close = requests.Session.close + original_close = httpx.Client.close - def _track_close(self: requests.Session) -> None: + def _track_close(self: httpx.Client) -> None: closed["closed"] = True original_close(self) - monkeypatch.setattr(requests.Session, "close", _track_close) + monkeypatch.setattr(httpx.Client, "close", _track_close) with pytest.raises(ValueError): GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", diff --git a/glpi_python_client/clients/tests/test_raise_site_audit.py b/glpi_python_client/clients/tests/test_raise_site_audit.py index b599a9f..fe75528 100644 --- a/glpi_python_client/clients/tests/test_raise_site_audit.py +++ b/glpi_python_client/clients/tests/test_raise_site_audit.py @@ -28,6 +28,9 @@ "GlpiServerError", "GlpiStatusError", "error_class", # status_error_class(...) dispatch result + # transport_error_from(...) returns GlpiTransportError or its + # GlpiTimeoutError subclass; the AST sees the factory, not the class. + "transport_error_from", "RuntimeError", # exempt by design -- see module docstring "TypeError", # exempt by design -- see module docstring } @@ -98,11 +101,19 @@ def test_no_bare_value_error_is_raised_by_library_code() -> None: assert offenders == [], f"bare ValueError raise sites remain: {offenders}" -def test_no_requests_exception_is_raised_by_library_code() -> None: - """The library never raises a third-party HTTP exception directly.""" +def test_no_third_party_http_exception_is_raised_by_library_code() -> None: + """The library never raises a third-party HTTP exception directly. - offenders = [site for site in _raise_sites() if site[2].startswith("requests.")] - assert offenders == [], f"requests exception raise sites remain: {offenders}" + Both spellings are checked, not just the transport currently in use: the + point of the audit is that a raise site cannot drift back to a + third-party type, and naming only the current library would let the + previous one silently reappear. + """ + + offenders = [ + site for site in _raise_sites() if site[2].startswith(("requests.", "httpx.")) + ] + assert offenders == [], f"third-party exception raise sites remain: {offenders}" def test_every_raise_site_uses_an_allowed_exception() -> None: diff --git a/glpi_python_client/testing/utils.py b/glpi_python_client/testing/utils.py index a939225..6058cc6 100644 --- a/glpi_python_client/testing/utils.py +++ b/glpi_python_client/testing/utils.py @@ -20,10 +20,15 @@ class FakeResponse: - """Small ``requests.Response`` stand-in for unit tests. + """Small HTTP-response stand-in for unit tests. The fake object implements only the attributes and ``json()`` behavior used - by the package's tests. + by the package's tests. It is duck-typed rather than a subclass of the + transport's response class, so it survived the move from ``requests`` to + ``httpx`` unchanged: the library reads the reason phrase through + :func:`~glpi_python_client.clients.commons._http.response_reason`, which + accepts either the ``reason`` spelling used here or the ``reason_phrase`` + spelling ``httpx`` uses. """ def __init__( diff --git a/pyproject.toml b/pyproject.toml index fb2732a..bfce818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,14 +48,13 @@ classifiers = [ ] dependencies = [ "beautifulsoup4>=4.12", + "httpx>=0.28", "lxml>=4.9", "markdown>=3.6", "markdownify>=0.13", "pydantic>=2.8", - "requests>=2.31", "tenacity>=8.2", "typing-extensions>=4.7; python_version < '3.11'", - "urllib3>=2.0", ] [project.optional-dependencies] @@ -78,7 +77,7 @@ dev = [ "sphinx-rtd-theme>=2.0", "tomli>=2.0; python_version < '3.11'", "twine>=5.1", - "types-requests>=2.32", + "unasync>=0.6", "vulture>=2.11", ] From 01b9d98fe56e5f18fb8221957547c1745e27d169 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 27 Jul 2026 16:12:59 +0200 Subject: [PATCH 2/9] build(codegen): add the unasync foundation and its collision guard Lands the machinery plan 4 needs before any of the 8,370 LOC under clients/ and auth/ moves: the build script, the two hand-written concurrency twins, the CI staleness gate, and the guard that covers what that gate structurally cannot. unasync_build.py generates glpi_python_client/_sync/ from _async/ and supports --check for CI. The substitution map is kept deliberately short -- httpx's names (unasync's built-in Async*->Sync* convention produces SyncClient, which does not exist) plus this package's own client class. Every other name is spelled identically in both trees, because each entry in that map is a chance for a silent collision. _concurrency.py is hand-written on both sides and excluded from generation. It is where the trees differ in kind rather than syntax: * gather is asyncio.gather on one side and plain sequential evaluation on the other. unasync leaves asyncio.gather intact, so a generated twin would call it from sync code. The call shape is identical because stripping await makes each argument evaluate eagerly -- which is what sequential execution is. * The auth lock cannot be substituted in either direction. A threading.Lock on the async side deadlocks: it is held across an await, so a second task blocks the loop and the holder can never resume. An asyncio.Lock on the sync side binds to the first loop that contends it and breaks cross-thread sharing -- latently, since acquire() only resolves the loop on the contended path, so uncontended use passes. The CI diff gate catches a stale _sync/ and nothing else. A token collision is deterministic, so regenerating reproduces it byte for byte, the diff stays clean and CI passes while the sync client is wrong. test_unasync_codegen.py scans for that directly. Running it over the existing 78 modules found zero genuine collisions -- the only hits are the intended renames -- which settles the dossier's one open risk that had no way of being checked before the tree existed. Codegen verified end to end on a representative sample first: async def, await, async with, async for, async generators, __aenter__/__aexit__, httpx.AsyncClient, aclose, AsyncIterator and __all__ all generate correctly, and mypy --strict accepts both trees including the duplicate module basenames. 572 passed, mypy strict clean (87 files), coverage 96.94%. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 8 + .pre-commit-config.yaml | 10 + glpi_python_client/_async/__init__.py | 15 ++ glpi_python_client/_async/_concurrency.py | 59 ++++++ glpi_python_client/_sync/__init__.py | 15 ++ glpi_python_client/_sync/_concurrency.py | 41 ++++ .../tests/test_unasync_codegen.py | 175 ++++++++++++++++ unasync_build.py | 196 ++++++++++++++++++ 8 files changed, 519 insertions(+) create mode 100644 glpi_python_client/_async/__init__.py create mode 100644 glpi_python_client/_async/_concurrency.py create mode 100644 glpi_python_client/_sync/__init__.py create mode 100644 glpi_python_client/_sync/_concurrency.py create mode 100644 glpi_python_client/tests/test_unasync_codegen.py create mode 100644 unasync_build.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75db468..c1beca2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,14 @@ jobs: - name: Run mypy run: python -m mypy glpi_python_client + # The sync client tree is generated from the async one and checked in. + # This catches a _sync/ left stale by an edit to _async/. It does NOT + # catch a token collision -- that is deterministic, so regenerating + # reproduces it and the diff stays clean. test_unasync_codegen.py + # covers that case. + - name: Check the generated sync tree is up to date + run: python unasync_build.py --check + - name: Build documentation run: python -m sphinx -W --keep-going -b html docs docs/_build/html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d41d78..3123776 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,16 @@ repos: types: [python] pass_filenames: false + # Fails on a _sync/ tree left stale by an edit to _async/. Runs at + # commit time rather than pre-push so the generated tree is never + # committed out of step with its source. + - id: unasync-check + name: generated sync tree is up to date + entry: python unasync_build.py --check + language: system + types: [python] + pass_filenames: false + - id: pytest-coverage name: pytest with coverage (>=95%) entry: python -m pytest -m "not integration" --cov=glpi_python_client --cov-fail-under=95 -q diff --git a/glpi_python_client/_async/__init__.py b/glpi_python_client/_async/__init__.py new file mode 100644 index 0000000..7fb9b80 --- /dev/null +++ b/glpi_python_client/_async/__init__.py @@ -0,0 +1,15 @@ +"""Hand-written client tree. + +This package is the single source of truth for client code. Its sibling +``glpi_python_client._sync`` is generated from it by ``unasync_build.py`` +and checked in; CI regenerates and diffs to keep the two from drifting. + +Prose written here is copied verbatim into the generated tree -- the +codegen rewrites tokens, not sentences. Docstrings under this package are +therefore worded so they read correctly on both surfaces: describe *what* a +helper does, and leave whether it blocks or awaits to the signature. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/glpi_python_client/_async/_concurrency.py b/glpi_python_client/_async/_concurrency.py new file mode 100644 index 0000000..4b3bd32 --- /dev/null +++ b/glpi_python_client/_async/_concurrency.py @@ -0,0 +1,59 @@ +"""Asyncio concurrency primitives for the async client tree. + +This module and its ``_sync`` twin are the only files maintained by hand on +both sides of the codegen. Everything else under ``_sync/`` is generated +from ``_async/`` by :mod:`unasync`, which rewrites tokens and strips +``async``/``await``. That works for syntax; it cannot work here, because the +two surfaces need genuinely *different primitives*, not differently-spelled +ones: + +* A fan-out is :func:`asyncio.gather` on this side and plain sequential + evaluation on the other. ``unasync`` leaves ``asyncio.gather`` untouched, + so a generated twin would call it from synchronous code and break. +* The auth lock must be an :class:`asyncio.Lock` here and a + :class:`threading.Lock` there -- see :data:`Lock` for why substituting one + for the other is wrong in *both* directions. + +Keeping both twins tiny is deliberate: hand-maintained duplication is a +liability, so it is confined to the smallest possible surface. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +#: Lock type guarding OAuth token acquisition on the async surface. +#: +#: An :class:`asyncio.Lock`, and the ``_sync`` twin uses a +#: :class:`threading.Lock`. Neither choice is substitutable for the other, +#: which is exactly why this is hand-written: +#: +#: * A :class:`threading.Lock` here would **deadlock**. The lock is held +#: across an ``await``, so a second task blocking on it blocks the whole +#: event loop -- and the task holding it can then never resume to release +#: it. A single-threaded loop has no way out of that. +#: * An :class:`asyncio.Lock` on the sync side would be worse than useless: +#: it is bound to the loop that first contends it, so sharing one client +#: across threads raises ``RuntimeError: ... bound to a different event +#: loop`` and can deadlock a thread permanently. The failure is latent -- +#: ``acquire()`` only looks up the loop on the *contended* path, so +#: uncontended use passes and tests stay green. +Lock = asyncio.Lock + + +async def gather(*awaitables: Any) -> list[Any]: + """Run ``awaitables`` concurrently and return their results in order. + + The ``_sync`` twin takes already-computed values and simply returns + them. That is not a stub: once ``unasync`` strips the ``await`` from a + call site, each argument expression evaluates eagerly at the point it is + written, which *is* sequential execution. So the same call shape -- + ``await gather(self.a(), self.b())`` -- means "concurrently" here and + "one after the other" there, with no change to the calling code. + """ + + return list(await asyncio.gather(*awaitables)) + + +__all__ = ["Lock", "gather"] diff --git a/glpi_python_client/_sync/__init__.py b/glpi_python_client/_sync/__init__.py new file mode 100644 index 0000000..7fb9b80 --- /dev/null +++ b/glpi_python_client/_sync/__init__.py @@ -0,0 +1,15 @@ +"""Hand-written client tree. + +This package is the single source of truth for client code. Its sibling +``glpi_python_client._sync`` is generated from it by ``unasync_build.py`` +and checked in; CI regenerates and diffs to keep the two from drifting. + +Prose written here is copied verbatim into the generated tree -- the +codegen rewrites tokens, not sentences. Docstrings under this package are +therefore worded so they read correctly on both surfaces: describe *what* a +helper does, and leave whether it blocks or awaits to the signature. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/glpi_python_client/_sync/_concurrency.py b/glpi_python_client/_sync/_concurrency.py new file mode 100644 index 0000000..0066805 --- /dev/null +++ b/glpi_python_client/_sync/_concurrency.py @@ -0,0 +1,41 @@ +"""Threading concurrency primitives for the sync client tree. + +Hand-written twin of ``glpi_python_client/_async/_concurrency.py`` -- see +that module for why these two files are the only ones not generated. + +**This file is not produced by the codegen and must be edited alongside its +async twin.** ``unasync_build.py`` excludes it by name. +""" + +from __future__ import annotations + +import threading +from typing import Any + +#: Lock type guarding OAuth token acquisition on the sync surface. +#: +#: A :class:`threading.Lock`, because one sync client may legitimately be +#: shared across user threads. An :class:`asyncio.Lock` here would bind +#: itself to whichever event loop first contended it and then raise +#: ``RuntimeError: ... bound to a different event loop`` -- or deadlock a +#: thread outright -- for every other caller. That failure is latent: +#: ``acquire()`` resolves the loop only on the contended path, so +#: uncontended use passes and the tests stay green. +Lock = threading.Lock + + +def gather(*values: Any) -> list[Any]: + """Return ``values`` unchanged, preserving order. + + This looks like a no-op and is doing real work. On the async side the + call reads ``await gather(self.a(), self.b())`` and runs the two + concurrently. Stripping the ``await`` leaves ``gather(self.a(), + self.b())``, where each argument has already been evaluated -- in + order -- by the time this is entered. Collecting them is therefore the + correct and complete synchronous meaning of the same expression. + """ + + return list(values) + + +__all__ = ["Lock", "gather"] diff --git a/glpi_python_client/tests/test_unasync_codegen.py b/glpi_python_client/tests/test_unasync_codegen.py new file mode 100644 index 0000000..7e60db5 --- /dev/null +++ b/glpi_python_client/tests/test_unasync_codegen.py @@ -0,0 +1,175 @@ +"""Guards for the ``_async`` -> ``_sync`` code generation. + +The generated tree is protected in CI by regenerating it and diffing. That +catches *staleness* -- someone editing ``_async/`` without rerunning the +build -- and nothing else. In particular it cannot catch the failure mode +that actually worries us: + +**A token collision is deterministic, so the diff stays empty.** If a local +variable, parameter or attribute happens to be spelled like a substitution +key, ``unasync`` rewrites it every single time. Regenerating produces +exactly the same wrong file, ``git diff`` is clean, CI is green, and the +sync client is silently incorrect. No amount of diffing finds that. + +These tests find it, by scanning the source for identifiers that the +substitution map would rewrite and failing on any that are not an +intentional rename. +""" + +from __future__ import annotations + +import ast +import pathlib +import subprocess +import sys + +import pytest + +unasync = pytest.importorskip( + "unasync", + reason="unasync is a dev-only dependency; codegen guards need it installed", +) + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_BUILD_SCRIPT = _REPO_ROOT / "unasync_build.py" +_ASYNC_DIR = _REPO_ROOT / "glpi_python_client" / "_async" +_SYNC_DIR = _REPO_ROOT / "glpi_python_client" / "_sync" + +#: Names the codegen is *supposed* to rewrite wherever they appear. +#: +#: Everything else in the substitution map is a language-level or +#: third-party name; if one of those shows up as an identifier this package +#: defines, it is a collision and the scan below fails. +_INTENTIONAL_RENAMES = { + "AsyncGlpiClient", + "AsyncClient", + "AsyncBaseTransport", + "AsyncHTTPTransport", + "aclose", + "aread", +} + + +def _build_module() -> object: + """Import ``unasync_build`` from the repository root.""" + + sys.path.insert(0, str(_REPO_ROOT)) + try: + import unasync_build + + return unasync_build + finally: + sys.path.remove(str(_REPO_ROOT)) + + +def _substitution_keys() -> set[str]: + """Return every NAME token the codegen would rewrite.""" + + build = _build_module() + rule = unasync.Rule( + fromdir=str(_ASYNC_DIR), + todir=str(_SYNC_DIR), + additional_replacements=build.TOKEN_REPLACEMENTS, # type: ignore[attr-defined] + ) + return set(rule.token_replacements) + + +def _identifier_sites() -> list[tuple[str, int, str]]: + """Return ``(module, lineno, identifier)`` for every name the package defines.""" + + sites: list[tuple[str, int, str]] = [] + for path in sorted(_ASYNC_DIR.rglob("*.py")): + if "__pycache__" in path.parts: + continue + rel = path.relative_to(_REPO_ROOT).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + sites.append((rel, node.lineno, node.name)) + elif isinstance(node, ast.arg): + sites.append((rel, node.lineno, node.arg)) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + sites.append((rel, node.lineno, node.id)) + elif isinstance(node, ast.Attribute): + sites.append((rel, node.lineno, node.attr)) + return sites + + +def test_the_substitution_map_is_reachable() -> None: + """Positive control: the map is non-empty and contains a known key. + + Without this, a refactor that made ``_substitution_keys`` return an + empty set would turn the collision scan into a test that can never + fail -- passing vacuously forever. + """ + + keys = _substitution_keys() + assert keys, "the substitution map is empty -- the collision scan is vacuous" + assert "AsyncGlpiClient" in keys + assert "__aenter__" in keys, "unasync's built-in defaults are missing" + + +def test_no_identifier_collides_with_a_substitution_key() -> None: + """No name this package defines is silently rewritten by the codegen. + + This is the check the CI diff gate cannot perform. A collision is + deterministic, so regenerating reproduces it byte for byte and the diff + stays clean while the generated client misbehaves. + """ + + keys = _substitution_keys() - _INTENTIONAL_RENAMES + offenders = [site for site in _identifier_sites() if site[2] in keys] + assert offenders == [], ( + "these identifiers would be silently rewritten by the codegen; " + f"rename them or add them to the intentional list: {offenders}" + ) + + +def test_the_concurrency_twins_expose_the_same_surface() -> None: + """Both hand-written ``_concurrency`` twins export the same names. + + These two files are the only ones maintained by hand on both sides, so + they are the only place the trees can diverge without the diff gate + noticing. If one grows a helper the other lacks, the generated tree + stops importing cleanly -- but only for the code paths that use it, + which may not be covered. + """ + + from glpi_python_client._async import _concurrency as async_twin + from glpi_python_client._sync import _concurrency as sync_twin + + assert set(async_twin.__all__) == set(sync_twin.__all__) + for name in async_twin.__all__: + assert hasattr(sync_twin, name), f"_sync/_concurrency.py is missing {name!r}" + + +def test_gather_twins_agree_on_ordering() -> None: + """The sync ``gather`` preserves order, as ``asyncio.gather`` does. + + Ordering is the whole contract callers rely on: results are matched to + arguments by position, never by completion time. + """ + + from glpi_python_client._sync._concurrency import gather + + assert gather("a", "b", "c") == ["a", "b", "c"] + assert gather() == [] + + +def test_the_checked_in_sync_tree_is_not_stale() -> None: + """``_sync/`` matches what ``_async/`` currently generates. + + Mirrors the CI gate so the failure shows up locally, at the pre-push + hook, rather than after a push. + """ + + result = subprocess.run( + [sys.executable, str(_BUILD_SCRIPT), "--check"], + capture_output=True, + text=True, + cwd=_REPO_ROOT, + ) + assert result.returncode == 0, ( + "the generated sync tree is out of date -- run `python unasync_build.py`\n" + f"{result.stdout}\n{result.stderr}" + ) diff --git a/unasync_build.py b/unasync_build.py new file mode 100644 index 0000000..9a48021 --- /dev/null +++ b/unasync_build.py @@ -0,0 +1,196 @@ +"""Generate the synchronous client tree from the asynchronous one. + +``glpi_python_client/_async/`` is the only hand-written client source. +``glpi_python_client/_sync/`` is produced from it by :mod:`unasync`, checked +into the repository, and verified in CI with ``--check``. Editing anything +under ``_sync/`` by hand is a mistake the check will catch. + +Usage +----- +``python unasync_build.py`` regenerate ``_sync/`` in place +``python unasync_build.py --check`` fail if ``_sync/`` is stale (CI gate) + +What the codegen does and does not do +------------------------------------- +:mod:`unasync` is a token-level rewriter. It strips ``async``/``await`` and +substitutes whole NAME tokens listed in the rule. Three consequences shape +this build: + +* It matches **single NAME tokens only**. A dotted key such as + ``"asyncio.Lock"`` can never match, and supplying one fails *silently* -- + the rule is accepted and simply never fires. Anything needing a dotted + name is handled by the hand-written ``_concurrency.py`` twins instead. +* It rewrites a **string literal whose entire content** is a substitution + key. That is what makes ``__all__ = ["AsyncGlpiClient"]`` generate + correctly. It is narrow: an embedded mention inside a longer string or a + docstring is left alone, so prose is safe. +* It leaves ``asyncio.gather`` and friends **intact**, which would produce + broken sync code. Those live only in ``_concurrency.py``, which is + excluded from generation and hand-written on both sides. +""" + +from __future__ import annotations + +import argparse +import difflib +import pathlib +import shutil +import sys +import tempfile + +import unasync + +REPO_ROOT = pathlib.Path(__file__).resolve().parent +PACKAGE = REPO_ROOT / "glpi_python_client" +ASYNC_DIR = PACKAGE / "_async" +SYNC_DIR = PACKAGE / "_sync" + +#: Files that are hand-written on *both* sides and never generated. +#: +#: ``_concurrency.py`` is the only one. It is where the two trees genuinely +#: differ in kind rather than in syntax: a fan-out is ``asyncio.gather`` on +#: one side and plain sequential evaluation on the other, and the auth lock +#: is an ``asyncio.Lock`` on one side and a ``threading.Lock`` on the other. +#: Token substitution cannot express either, so both files are maintained by +#: hand and kept deliberately tiny. +HAND_WRITTEN = {"_concurrency.py"} + +#: Token substitutions beyond unasync's defaults. +#: +#: The defaults cover the language-level names (``__aenter__``, +#: ``AsyncIterator``, ``StopAsyncIteration``, ...). These cover the two +#: things the defaults get wrong or do not know about: +#: +#: * **httpx naming.** unasync's built-in ``Async*`` -> ``Sync*`` convention +#: would produce ``SyncClient``, which does not exist; the real name is +#: ``Client``. Same for the transport classes. +#: * **This package's own public class name**, which differs between the two +#: surfaces by design. +#: +#: Everything else -- mixins, helpers, module names -- is spelled +#: *identically* in both trees. Keeping the rename list this short is +#: deliberate: every entry is a chance for a silent collision, and the +#: shorter the list, the smaller that surface. +TOKEN_REPLACEMENTS = { + "AsyncGlpiClient": "GlpiClient", + "AsyncClient": "Client", + "AsyncBaseTransport": "BaseTransport", + "AsyncHTTPTransport": "HTTPTransport", + "aclose": "close", + "aread": "read", +} + + +def _source_files() -> list[pathlib.Path]: + """Return every ``_async/`` module that should be generated from.""" + + return sorted( + path + for path in ASYNC_DIR.rglob("*.py") + if path.name not in HAND_WRITTEN and "__pycache__" not in path.parts + ) + + +def _generate(into: pathlib.Path) -> None: + """Run unasync over the async tree, writing the sync tree into ``into``.""" + + rule = unasync.Rule( + fromdir=str(ASYNC_DIR), + todir=str(into), + additional_replacements=TOKEN_REPLACEMENTS, + ) + unasync.unasync_files([str(p) for p in _source_files()], [rule]) + + # The hand-written twins are never generated. When building into a + # scratch directory for --check they must still be carried across, or + # the comparison would report them as missing from the generated tree + # and demand their deletion. Generating in place leaves them untouched. + if into == SYNC_DIR: + return + for name in sorted(HAND_WRITTEN): + for existing in SYNC_DIR.rglob(name): + target = into / existing.relative_to(SYNC_DIR) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(existing, target) + + +def _relative_sync_files(root: pathlib.Path) -> dict[pathlib.Path, str]: + """Return ``{relative path: text}`` for every module under ``root``.""" + + return { + path.relative_to(root): path.read_text(encoding="utf-8") + for path in sorted(root.rglob("*.py")) + if "__pycache__" not in path.parts + } + + +def _check() -> int: + """Regenerate into a temp dir and report any drift from the checked-in tree.""" + + with tempfile.TemporaryDirectory() as tmp: + scratch = pathlib.Path(tmp) / "_sync" + _generate(scratch) + expected = _relative_sync_files(scratch) + actual = _relative_sync_files(SYNC_DIR) + + problems: list[str] = [] + for rel in sorted(set(expected) | set(actual)): + want = expected.get(rel) + have = actual.get(rel) + if want == have: + continue + if want is None: + problems.append(f"{rel}: present in _sync/ but not generated by _async/") + continue + if have is None: + problems.append( + f"{rel}: missing from _sync/ -- run: python unasync_build.py" + ) + continue + diff = "".join( + difflib.unified_diff( + have.splitlines(keepends=True), + want.splitlines(keepends=True), + fromfile=f"_sync/{rel} (checked in)", + tofile=f"_sync/{rel} (regenerated)", + ) + ) + problems.append(diff) + + if problems: + print("_sync/ is out of date with respect to _async/.\n") + print("\n".join(problems)) + print( + "\nThe sync tree is generated. Edit glpi_python_client/_async/ and " + "run `python unasync_build.py`." + ) + return 1 + + print(f"_sync/ is up to date ({len(actual)} modules).") + return 0 + + +def main() -> int: + """Entry point for both regeneration and the CI staleness check.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="verify _sync/ matches what _async/ generates instead of writing it", + ) + args = parser.parse_args() + + if not ASYNC_DIR.is_dir(): + print(f"no async source tree at {ASYNC_DIR}", file=sys.stderr) + return 1 + if args.check: + return _check() + + _generate(SYNC_DIR) + print(f"regenerated {SYNC_DIR} from {len(_source_files())} async modules.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4d3d3f9123e87055acca52d42bbf6cb0fcf869b6 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 27 Jul 2026 16:31:06 +0200 Subject: [PATCH 3/9] fix(models): accept GLPI's sixth priority level GLPI's priority scale runs 1-6, where 6 is "Major"; the published contract advertises 1-5 and GlpiPriority followed the contract. GetTicket.priority is typed with that enum and pydantic validates every record in a result set, so one escalated ticket raised ValidationError and failed the entire search -- not just that row. A reporting query filtering on high priority is the most likely place to meet one. Adds GlpiPriority.MAJOR = 6, leaving the five existing members on their current identifiers so stored filters keep their meaning. urgency and impact really do stop at 5 and now accept a value GLPI will never send; accepting an impossible value is harmless, rejecting a real one was not. 574 passed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 ++++++++ .../tests/test_assistance_schemas.py | 29 +++++++++++++++++++ glpi_python_client/models/api_schema/enums.py | 19 +++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cab48a1..d64b802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A `Major` priority ticket made the whole search fail.** GLPI's priority + scale has six levels; the published contract advertises five, and + `GlpiPriority` followed the contract. Since `GetTicket.priority` is typed + with that enum and validation runs per record, a single escalated ticket + anywhere in a result set raised `ValidationError` and took the entire + query down with it — most likely to bite exactly the reporting queries + that filter on high priority. `GlpiPriority.MAJOR = 6` is now defined. + The five existing members keep their identifiers, so stored filters are + unaffected. `urgency` and `impact` genuinely do stop at 5 and now accept a + value GLPI will never send, which is harmless in the direction that + matters. + - **The statistics layer sent GLPI v1 field names to the v2 API, which silently ignored them and returned unfiltered results.** v2 drops a `filter=` conjunct whose field it does not recognise, honours the rest, diff --git a/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py b/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py index 43d699f..e0eb190 100644 --- a/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py +++ b/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py @@ -47,6 +47,35 @@ def test_get_ticket_validates_rich_payload() -> None: assert ticket.team[0].role == "requester" +def test_get_ticket_accepts_the_major_priority_level() -> None: + """A ``Major`` (6) priority ticket validates. + + GLPI's priority scale has six levels while the published contract + advertises five, so ``GetTicket`` used to raise ``ValidationError`` on + any ticket GLPI had escalated to ``Major``. Because validation happens + per record inside a search, one such ticket failed the *entire* query -- + and a reporting query filtering on high priority is precisely where it + would show up. + """ + + ticket = GetTicket.model_validate({"id": 1, "name": "major", "priority": 6}) + assert ticket.priority is GlpiPriority.MAJOR + assert ticket.priority.glpi_id == 6 + + +def test_urgency_and_impact_still_span_one_to_five() -> None: + """The five contract-declared levels keep their identifiers. + + Widening the shared enum must not renumber the levels that were already + correct: these values are sent back to GLPI in filters, so a shift would + silently reinterpret every stored query. + """ + + assert [member.value for member in GlpiPriority] == [1, 2, 3, 4, 5, 6] + assert GlpiPriority.VERY_HIGH.value == 5 + assert GlpiPriority.VERY_HIGH.rsql_equals("priority") == "priority==5" + + def test_post_ticket_excludes_read_only_fields() -> None: """Read-only contract fields are captured in ``extra_payload``. diff --git a/glpi_python_client/models/api_schema/enums.py b/glpi_python_client/models/api_schema/enums.py index 9ba071c..475e8d3 100644 --- a/glpi_python_client/models/api_schema/enums.py +++ b/glpi_python_client/models/api_schema/enums.py @@ -69,7 +69,23 @@ class GlpiPriority(GlpiEnum): """Common GLPI urgency, impact, and priority identifiers. The contract advertises the same ``[1, 2, 3, 4, 5]`` enum on the - ``urgency``, ``impact``, and ``priority`` ticket fields. + ``urgency``, ``impact``, and ``priority`` ticket fields, but the live + server does not honour that for ``priority``: GLPI's priority scale has + a sixth level, ``Major``, which it derives from urgency and impact. + ``urgency`` and ``impact`` really do stop at 5. + + :attr:`MAJOR` is therefore included even though the contract omits it, + following the same rule the timeline helpers use -- observed server + behaviour wins over the published contract. Leaving it out was not a + cosmetic gap: ``priority`` is typed with this enum on ``GetTicket``, so + a single ``Major`` ticket anywhere in a result set failed validation and + took the whole search down with it, which is exactly the kind of ticket + a reporting query is most likely to touch. + + The cost of sharing one enum across the three fields is that ``urgency`` + and ``impact`` now also accept 6, which GLPI will never send. Accepting + a value that cannot occur is harmless; rejecting one that does occur was + not. """ VERY_LOW = 1 @@ -77,6 +93,7 @@ class GlpiPriority(GlpiEnum): MEDIUM = 3 HIGH = 4 VERY_HIGH = 5 + MAJOR = 6 class GlpiTicketType(GlpiEnum): From 0829878710a3c4ce456bf025ac86bed60011e6b4 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 27 Jul 2026 17:40:51 +0200 Subject: [PATCH 4/9] feat(async)!: generate the sync client from a real async one Replaces the async facade with genuine non-blocking I/O and collapses the two client surfaces into one codebase. AsyncGlpiClient used to wrap every synchronous method in asyncio.to_thread, so "async" meant "blocking call on a worker thread". It now runs on httpx.AsyncClient with no thread pool and no executor. glpi_python_client/_async/ is hand-written; _sync/ is generated from it by unasync_build.py, committed, and diffed in CI. Endpoint logic exists exactly once, which removes the failure mode the bridge kept producing: a sync body calling a sibling public method through self got back an un-awaited coroutine and silently did nothing. Three shipped bugs of that shape were fixed by hand this year; the shape is now unreachable. Deletes 1,235 LOC of hand-written async overrides the bridge forced into existence -- including _statistics_async.py, 500 lines duplicating the most intricate logic in the package with no test asserting the two copies agreed. Fan-out survives as a `gather` helper written once at the call site: asyncio.gather on the async surface, sequential evaluation on the generated one, because stripping `await` already makes each argument evaluate eagerly. Two findings worth recording, both from running rather than reading: * The dossier says not to substitute asyncio.Lock for threading.Lock but not what to do instead. A threading.Lock on the async surface deadlocks outright -- it is held across an await, so a second task blocks the loop and the holder can never resume to release it. _concurrency.py is hand-written per surface for that reason, and test_async_surface.py drives ten contending tasks through the token path to prove it. * build_client_resources built the session and unwound it in an except clause. That is inexpressible on the async surface: httpx.AsyncClient has no synchronous close and __init__ cannot await one. Credentials are now validated before anything is constructed, so there is nothing to unwind -- and the test asserts no session is built at all, which is the stronger property. Tests move to glpi_python_client/tests/ and keep exercising the shipped sync client. test_async_surface.py covers what the codegen cannot: that the async tree actually awaits, contends its lock without deadlocking, overlaps its fan-out, and translates faults inside an awaited try block. Coverage is measured on the generated tree only -- both trees contain the same statements, so counting both would hide real gaps behind a doubled denominator; the correspondence is enforced by --check and mypy. Public imports are unchanged. Private module paths gain a tree segment. 523 passed, mypy strict clean (119 files, both trees), coverage 96.95%, sphinx -W clean, _sync/ verified identical to regeneration. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 22 + CONTRIBUTING.md | 11 +- docs/api_reference.rst | 4 - docs/development.md | 54 +- docs/development_rtd.rst | 12 +- docs/user_guide.rst | 91 +- glpi_python_client/__init__.py | 3 +- .../{ => _async}/auth/__init__.py | 6 +- glpi_python_client/_async/auth/_v1_session.py | 466 +++++++++ glpi_python_client/_async/auth/auth.py | 416 ++++++++ glpi_python_client/_async/clients/__init__.py | 17 + .../{ => _async}/clients/_base_client.py | 21 +- .../_async/clients/api/__init__.py | 54 + .../clients/api/administration/__init__.py | 13 + .../clients/api/administration/_entity.py | 217 ++++ .../clients/api/administration/_user.py | 224 ++++ .../_async/clients/api/assistance/__init__.py | 8 + .../_async/clients/api/assistance/_team.py | 121 +++ .../_async/clients/api/assistance/_ticket.py | 238 +++++ .../api/assistance/timeline/__init__.py | 23 + .../api/assistance/timeline/_document.py | 225 ++++ .../api/assistance/timeline/_followup.py | 204 ++++ .../api/assistance/timeline/_solution.py | 201 ++++ .../clients/api/assistance/timeline/_task.py | 194 ++++ .../_async/clients/api/dropdowns/__init__.py | 7 + .../_async/clients/api/dropdowns/_location.py | 170 +++ .../clients/api/knowledgebase/__init__.py | 21 + .../clients/api/knowledgebase/_article.py | 214 ++++ .../clients/api/knowledgebase/_category.py | 120 +++ .../clients/api/knowledgebase/_comment.py | 109 ++ .../clients/api/knowledgebase/_revision.py | 70 ++ .../_async/clients/api/management/__init__.py | 7 + .../clients/api/management/_document.py | 281 +++++ .../_async/clients/api/plugins/__init__.py | 10 + .../_async/clients/api/plugins/_fields.py | 426 ++++++++ glpi_python_client/_async/clients/client.py | 135 +++ .../{ => _async}/clients/commons/__init__.py | 4 +- .../_async/clients/commons/_config.py | 316 ++++++ .../clients/commons/_constants.py | 0 .../{ => _async}/clients/commons/_filters.py | 0 .../_async/clients/commons/_http.py | 394 +++++++ .../{ => _async}/clients/commons/_payloads.py | 0 .../_async/clients/commons/_transport.py | 573 +++++++++++ .../_async/clients/custom/__init__.py | 23 + .../_async/clients/custom/_statistics.py | 973 ++++++++++++++++++ .../_async/clients/custom/_ticket_context.py | 74 ++ glpi_python_client/_sync/auth/__init__.py | 13 + .../{ => _sync}/auth/_v1_session.py | 18 +- glpi_python_client/{ => _sync}/auth/auth.py | 118 ++- glpi_python_client/_sync/clients/__init__.py | 17 + .../_sync/clients/_base_client.py | 177 ++++ .../{ => _sync}/clients/api/__init__.py | 22 +- .../clients/api/administration/__init__.py | 13 + .../clients/api/administration/_entity.py | 12 +- .../clients/api/administration/_user.py | 10 +- .../_sync/clients/api/assistance/__init__.py | 8 + .../clients/api/assistance/_team.py | 16 +- .../clients/api/assistance/_ticket.py | 12 +- .../api/assistance/timeline/__init__.py | 23 + .../api/assistance/timeline/_document.py | 12 +- .../api/assistance/timeline/_followup.py | 14 +- .../api/assistance/timeline/_solution.py | 14 +- .../clients/api/assistance/timeline/_task.py | 14 +- .../_sync/clients/api/dropdowns/__init__.py | 7 + .../clients/api/dropdowns/_location.py | 15 +- .../clients/api/knowledgebase/__init__.py | 21 + .../clients/api/knowledgebase/_article.py | 18 +- .../clients/api/knowledgebase/_category.py | 12 +- .../clients/api/knowledgebase/_comment.py | 12 +- .../clients/api/knowledgebase/_revision.py | 8 +- .../_sync/clients/api/management/__init__.py | 7 + .../clients/api/management/_document.py | 20 +- .../_sync/clients/api/plugins/__init__.py | 10 + .../clients/api/plugins/_fields.py | 18 +- .../clients/client.py} | 52 +- .../_sync/clients/commons/__init__.py | 9 + .../{ => _sync}/clients/commons/_config.py | 56 +- .../_sync/clients/commons/_constants.py | 60 ++ .../_sync/clients/commons/_filters.py | 105 ++ .../{ => _sync}/clients/commons/_http.py | 2 +- .../_sync/clients/commons/_payloads.py | 42 + .../{ => _sync}/clients/commons/_transport.py | 62 +- .../_sync/clients/custom/__init__.py | 23 + .../{ => _sync}/clients/custom/_statistics.py | 10 +- .../clients/custom/_ticket_context.py | 31 +- glpi_python_client/clients/__init__.py | 20 - .../clients/api/administration/__init__.py | 13 - .../clients/api/assistance/__init__.py | 8 - .../api/assistance/timeline/__init__.py | 23 - .../clients/api/dropdowns/__init__.py | 7 - .../clients/api/knowledgebase/__init__.py | 23 - .../api/knowledgebase/_article_async.py | 152 --- .../knowledgebase/tests/test_article_async.py | 236 ----- .../clients/api/management/__init__.py | 7 - .../clients/api/plugins/__init__.py | 11 - .../clients/api/plugins/_fields_async.py | 157 --- .../api/plugins/tests/test_fields_async.py | 147 --- glpi_python_client/clients/async_client.py | 186 ---- .../clients/commons/_async_bridge.py | 191 ---- glpi_python_client/clients/custom/__init__.py | 34 - .../clients/custom/_pagination_async.py | 164 --- .../clients/custom/_statistics_async.py | 555 ---------- .../clients/custom/_ticket_context_async.py | 73 -- .../custom/tests/test_statistics_async.py | 249 ----- .../clients/tests/test_async_branches.py | 792 -------------- .../tests/test_async_selfcall_guard.py | 226 ---- .../clients/tests/test_async_smoke.py | 150 --- .../clients/tests/test_parity.py | 70 -- .../models/api_schema/_content.py | 2 +- .../tests/test_content_roundtrip.py | 2 +- .../tests/test_article_schema.py | 2 +- .../tests/test_comment_schema.py | 2 +- .../api_knowledgebase}/__init__.py | 0 .../api_knowledgebase}/test_article_mixin.py | 0 .../api_knowledgebase}/test_category_mixin.py | 0 .../api_knowledgebase}/test_comment_mixin.py | 0 .../test_kb_failure_paths.py | 0 .../api_knowledgebase}/test_revision_mixin.py | 0 .../tests => tests/api_plugins}/__init__.py | 0 .../api_plugins}/test_fields_mixin.py | 4 +- glpi_python_client/tests/auth/__init__.py | 0 .../{auth/tests => tests/auth}/test_auth.py | 2 +- .../tests => tests/auth}/test_v1_session.py | 2 +- .../tests => tests/clients}/__init__.py | 0 .../clients}/test_api_coverage.py | 0 .../clients}/test_glpi_client.py | 29 +- .../clients}/test_method_invocation.py | 71 +- .../clients}/test_raise_site_audit.py | 5 +- .../tests => tests/clients}/test_smoke.py | 0 .../tests => tests/commons}/__init__.py | 2 +- .../tests => tests/commons}/test_constants.py | 2 +- .../tests => tests/commons}/test_filters.py | 4 +- .../tests => tests/commons}/test_http.py | 4 +- .../tests => tests/commons}/test_payloads.py | 4 +- .../commons}/test_retry_semantics.py | 2 +- .../tests => tests/commons}/test_transport.py | 0 glpi_python_client/tests/custom/__init__.py | 0 .../tests => tests/custom}/test_statistics.py | 2 +- .../custom}/test_ticket_context.py | 2 +- .../tests/test_async_surface.py | 231 +++++ .../tests/test_unasync_codegen.py | 4 + pyproject.toml | 16 + unasync_build.py | 4 + 143 files changed, 7872 insertions(+), 3905 deletions(-) rename glpi_python_client/{ => _async}/auth/__init__.py (56%) create mode 100644 glpi_python_client/_async/auth/_v1_session.py create mode 100644 glpi_python_client/_async/auth/auth.py create mode 100644 glpi_python_client/_async/clients/__init__.py rename glpi_python_client/{ => _async}/clients/_base_client.py (90%) create mode 100644 glpi_python_client/_async/clients/api/__init__.py create mode 100644 glpi_python_client/_async/clients/api/administration/__init__.py create mode 100644 glpi_python_client/_async/clients/api/administration/_entity.py create mode 100644 glpi_python_client/_async/clients/api/administration/_user.py create mode 100644 glpi_python_client/_async/clients/api/assistance/__init__.py create mode 100644 glpi_python_client/_async/clients/api/assistance/_team.py create mode 100644 glpi_python_client/_async/clients/api/assistance/_ticket.py create mode 100644 glpi_python_client/_async/clients/api/assistance/timeline/__init__.py create mode 100644 glpi_python_client/_async/clients/api/assistance/timeline/_document.py create mode 100644 glpi_python_client/_async/clients/api/assistance/timeline/_followup.py create mode 100644 glpi_python_client/_async/clients/api/assistance/timeline/_solution.py create mode 100644 glpi_python_client/_async/clients/api/assistance/timeline/_task.py create mode 100644 glpi_python_client/_async/clients/api/dropdowns/__init__.py create mode 100644 glpi_python_client/_async/clients/api/dropdowns/_location.py create mode 100644 glpi_python_client/_async/clients/api/knowledgebase/__init__.py create mode 100644 glpi_python_client/_async/clients/api/knowledgebase/_article.py create mode 100644 glpi_python_client/_async/clients/api/knowledgebase/_category.py create mode 100644 glpi_python_client/_async/clients/api/knowledgebase/_comment.py create mode 100644 glpi_python_client/_async/clients/api/knowledgebase/_revision.py create mode 100644 glpi_python_client/_async/clients/api/management/__init__.py create mode 100644 glpi_python_client/_async/clients/api/management/_document.py create mode 100644 glpi_python_client/_async/clients/api/plugins/__init__.py create mode 100644 glpi_python_client/_async/clients/api/plugins/_fields.py create mode 100644 glpi_python_client/_async/clients/client.py rename glpi_python_client/{ => _async}/clients/commons/__init__.py (58%) create mode 100644 glpi_python_client/_async/clients/commons/_config.py rename glpi_python_client/{ => _async}/clients/commons/_constants.py (100%) rename glpi_python_client/{ => _async}/clients/commons/_filters.py (100%) create mode 100644 glpi_python_client/_async/clients/commons/_http.py rename glpi_python_client/{ => _async}/clients/commons/_payloads.py (100%) create mode 100644 glpi_python_client/_async/clients/commons/_transport.py create mode 100644 glpi_python_client/_async/clients/custom/__init__.py create mode 100644 glpi_python_client/_async/clients/custom/_statistics.py create mode 100644 glpi_python_client/_async/clients/custom/_ticket_context.py create mode 100644 glpi_python_client/_sync/auth/__init__.py rename glpi_python_client/{ => _sync}/auth/_v1_session.py (98%) rename glpi_python_client/{ => _sync}/auth/auth.py (81%) create mode 100644 glpi_python_client/_sync/clients/__init__.py create mode 100644 glpi_python_client/_sync/clients/_base_client.py rename glpi_python_client/{ => _sync}/clients/api/__init__.py (57%) create mode 100644 glpi_python_client/_sync/clients/api/administration/__init__.py rename glpi_python_client/{ => _sync}/clients/api/administration/_entity.py (94%) rename glpi_python_client/{ => _sync}/clients/api/administration/_user.py (95%) create mode 100644 glpi_python_client/_sync/clients/api/assistance/__init__.py rename glpi_python_client/{ => _sync}/clients/api/assistance/_team.py (86%) rename glpi_python_client/{ => _sync}/clients/api/assistance/_ticket.py (95%) create mode 100644 glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py rename glpi_python_client/{ => _sync}/clients/api/assistance/timeline/_document.py (94%) rename glpi_python_client/{ => _sync}/clients/api/assistance/timeline/_followup.py (92%) rename glpi_python_client/{ => _sync}/clients/api/assistance/timeline/_solution.py (92%) rename glpi_python_client/{ => _sync}/clients/api/assistance/timeline/_task.py (92%) create mode 100644 glpi_python_client/_sync/clients/api/dropdowns/__init__.py rename glpi_python_client/{ => _sync}/clients/api/dropdowns/_location.py (92%) create mode 100644 glpi_python_client/_sync/clients/api/knowledgebase/__init__.py rename glpi_python_client/{ => _sync}/clients/api/knowledgebase/_article.py (94%) rename glpi_python_client/{ => _sync}/clients/api/knowledgebase/_category.py (91%) rename glpi_python_client/{ => _sync}/clients/api/knowledgebase/_comment.py (90%) rename glpi_python_client/{ => _sync}/clients/api/knowledgebase/_revision.py (89%) create mode 100644 glpi_python_client/_sync/clients/api/management/__init__.py rename glpi_python_client/{ => _sync}/clients/api/management/_document.py (93%) create mode 100644 glpi_python_client/_sync/clients/api/plugins/__init__.py rename glpi_python_client/{ => _sync}/clients/api/plugins/_fields.py (96%) rename glpi_python_client/{clients/sync_client.py => _sync/clients/client.py} (63%) create mode 100644 glpi_python_client/_sync/clients/commons/__init__.py rename glpi_python_client/{ => _sync}/clients/commons/_config.py (87%) create mode 100644 glpi_python_client/_sync/clients/commons/_constants.py create mode 100644 glpi_python_client/_sync/clients/commons/_filters.py rename glpi_python_client/{ => _sync}/clients/commons/_http.py (99%) create mode 100644 glpi_python_client/_sync/clients/commons/_payloads.py rename glpi_python_client/{ => _sync}/clients/commons/_transport.py (91%) create mode 100644 glpi_python_client/_sync/clients/custom/__init__.py rename glpi_python_client/{ => _sync}/clients/custom/_statistics.py (99%) rename glpi_python_client/{ => _sync}/clients/custom/_ticket_context.py (54%) delete mode 100644 glpi_python_client/clients/__init__.py delete mode 100644 glpi_python_client/clients/api/administration/__init__.py delete mode 100644 glpi_python_client/clients/api/assistance/__init__.py delete mode 100644 glpi_python_client/clients/api/assistance/timeline/__init__.py delete mode 100644 glpi_python_client/clients/api/dropdowns/__init__.py delete mode 100644 glpi_python_client/clients/api/knowledgebase/__init__.py delete mode 100644 glpi_python_client/clients/api/knowledgebase/_article_async.py delete mode 100644 glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py delete mode 100644 glpi_python_client/clients/api/management/__init__.py delete mode 100644 glpi_python_client/clients/api/plugins/__init__.py delete mode 100644 glpi_python_client/clients/api/plugins/_fields_async.py delete mode 100644 glpi_python_client/clients/api/plugins/tests/test_fields_async.py delete mode 100644 glpi_python_client/clients/async_client.py delete mode 100644 glpi_python_client/clients/commons/_async_bridge.py delete mode 100644 glpi_python_client/clients/custom/__init__.py delete mode 100644 glpi_python_client/clients/custom/_pagination_async.py delete mode 100644 glpi_python_client/clients/custom/_statistics_async.py delete mode 100644 glpi_python_client/clients/custom/_ticket_context_async.py delete mode 100644 glpi_python_client/clients/custom/tests/test_statistics_async.py delete mode 100644 glpi_python_client/clients/tests/test_async_branches.py delete mode 100644 glpi_python_client/clients/tests/test_async_selfcall_guard.py delete mode 100644 glpi_python_client/clients/tests/test_async_smoke.py delete mode 100644 glpi_python_client/clients/tests/test_parity.py rename glpi_python_client/{clients/api/knowledgebase/tests => tests/api_knowledgebase}/__init__.py (100%) rename glpi_python_client/{clients/api/knowledgebase/tests => tests/api_knowledgebase}/test_article_mixin.py (100%) rename glpi_python_client/{clients/api/knowledgebase/tests => tests/api_knowledgebase}/test_category_mixin.py (100%) rename glpi_python_client/{clients/api/knowledgebase/tests => tests/api_knowledgebase}/test_comment_mixin.py (100%) rename glpi_python_client/{clients/api/knowledgebase/tests => tests/api_knowledgebase}/test_kb_failure_paths.py (100%) rename glpi_python_client/{clients/api/knowledgebase/tests => tests/api_knowledgebase}/test_revision_mixin.py (100%) rename glpi_python_client/{clients/api/plugins/tests => tests/api_plugins}/__init__.py (100%) rename glpi_python_client/{clients/api/plugins/tests => tests/api_plugins}/test_fields_mixin.py (98%) create mode 100644 glpi_python_client/tests/auth/__init__.py rename glpi_python_client/{auth/tests => tests/auth}/test_auth.py (99%) rename glpi_python_client/{auth/tests => tests/auth}/test_v1_session.py (99%) rename glpi_python_client/{clients/tests => tests/clients}/__init__.py (100%) rename glpi_python_client/{clients/tests => tests/clients}/test_api_coverage.py (100%) rename glpi_python_client/{clients/tests => tests/clients}/test_glpi_client.py (89%) rename glpi_python_client/{clients/tests => tests/clients}/test_method_invocation.py (81%) rename glpi_python_client/{clients/tests => tests/clients}/test_raise_site_audit.py (96%) rename glpi_python_client/{clients/tests => tests/clients}/test_smoke.py (100%) rename glpi_python_client/{clients/commons/tests => tests/commons}/__init__.py (63%) rename glpi_python_client/{clients/commons/tests => tests/commons}/test_constants.py (88%) rename glpi_python_client/{clients/commons/tests => tests/commons}/test_filters.py (94%) rename glpi_python_client/{clients/commons/tests => tests/commons}/test_http.py (97%) rename glpi_python_client/{clients/commons/tests => tests/commons}/test_payloads.py (93%) rename glpi_python_client/{clients/commons/tests => tests/commons}/test_retry_semantics.py (98%) rename glpi_python_client/{clients/commons/tests => tests/commons}/test_transport.py (100%) create mode 100644 glpi_python_client/tests/custom/__init__.py rename glpi_python_client/{clients/custom/tests => tests/custom}/test_statistics.py (99%) rename glpi_python_client/{clients/custom/tests => tests/custom}/test_ticket_context.py (94%) create mode 100644 glpi_python_client/tests/test_async_surface.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d64b802..50a4e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **`AsyncGlpiClient` now performs real non-blocking I/O.** It was a facade + that wrapped each synchronous method in `asyncio.to_thread`, so "async" + meant "blocking call on a worker thread". It is now genuinely + asynchronous, built on `httpx.AsyncClient`, with no thread pool and no + executor. The `executor` constructor keyword is gone, as is + `AsyncBridge`. + - The two clients are now one codebase. `glpi_python_client/_async/` is + hand-written and `glpi_python_client/_sync/` is generated from it by + `unasync_build.py`, committed, and diffed in CI. Endpoint logic exists + exactly once, so the two surfaces cannot drift. + - This deletes the six hand-written async override modules the bridge + forced into existence — including the 500-line `_statistics_async.py`, + which duplicated the most intricate logic in the package with no test + asserting the two copies agreed. + - Aggregating helpers keep their concurrency through a shared `gather` + helper that is `asyncio.gather` on the async surface and sequential + evaluation on the generated one, written once at the call site. + - **Public imports are unchanged**: `from glpi_python_client import + GlpiClient, AsyncGlpiClient` still works. Code importing private + module paths (`glpi_python_client.clients.*`, `glpi_python_client.auth.*`) + must add the tree segment, e.g. + `glpi_python_client._sync.clients.commons._transport`. - **Breaking: the HTTP transport moved from `requests` to `httpx`.** `requests` and `urllib3` are no longer dependencies. The v2 transport, the legacy v1 session, and the OAuth token manager were swapped together in a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f13399..17f8887 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,10 +51,13 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html ## Design Guidelines - Keep API calls behind `GlpiClient` / `AsyncGlpiClient` methods. Add - new endpoints to a sync endpoint mixin only; `AsyncGlpiClient` - exposes them as coroutines automatically through `AsyncBridge`. Only - add a dedicated async override when the method needs concurrent - fan-out via `asyncio.gather`. + new endpoints to the async endpoint mixin under + `glpi_python_client/_async/`, then run `python unasync_build.py` to + regenerate `glpi_python_client/_sync/` and commit both. Never edit + `_sync/` by hand; CI regenerates it and fails on any difference. For + concurrent fan-out use `gather` from `_async/_concurrency.py` rather + than `asyncio.gather` directly, so the generated sync code stays + correct. - Prefer field-validated Pydantic models for request and response payloads. - Avoid organization-specific category, entity, or profile defaults in the library core. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index c0fd71c..11b58c6 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -24,10 +24,6 @@ the asynchronous one wraps each synchronous method into a coroutine. :inherited-members: :show-inheritance: -.. autoclass:: glpi_python_client.clients.commons._async_bridge.AsyncBridge - :members: - :show-inheritance: - Exceptions ---------- diff --git a/docs/development.md b/docs/development.md index 03314f1..e65baa8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,34 +42,33 @@ python -m pytest - `glpi_python_client.__init__` exposes the public import surface, including both client classes and the Pydantic models. -- `glpi_python_client.clients.sync_client.GlpiClient` is the +- `glpi_python_client._sync.clients.client.GlpiClient` is the synchronous, blocking client. It is the single source of truth for endpoint behaviour: each public method lives on one of the sync - endpoint mixins under `glpi_python_client.clients.api.*` and - `glpi_python_client.clients.custom.*`. -- `glpi_python_client.clients.async_client.AsyncGlpiClient` is the + endpoint mixins under `glpi_python_client._async.clients.api.*` and + `glpi_python_client._async.clients.custom.*`. +- `glpi_python_client._async.clients.client.AsyncGlpiClient` is the asynchronous facade. It inherits the same endpoint mixins and uses - `glpi_python_client.clients.commons._async_bridge.AsyncBridge` to wrap + `glpi_python_client._async._concurrency` to wrap every inherited public sync method into a coroutine dispatched on a worker thread (`asyncio.to_thread` by default, or a caller-supplied `concurrent.futures.Executor`). -- `glpi_python_client.clients.commons` holds the reusable building +- `glpi_python_client._async.clients.commons` holds the reusable building blocks shared by every endpoint mixin: configuration helpers (`_config`), constants (`_constants`), errors (`_errors`), filters (`_filters`), HTTP helpers (`_http`), payload builders (`_payloads`), - the synchronous `TransportMixin` (`_transport`), and the - `AsyncBridge` (`_async_bridge`). A shared `threading.Lock` in the - transport serialises OAuth token acquisition so concurrent - `asyncio.gather` fan-outs on the async client cannot race. -- `glpi_python_client.clients.api.*` contains the contract-aligned + and the `TransportMixin` (`_transport`). The transport serialises OAuth + token acquisition with the lock from `_async/_concurrency.py`, so + concurrent callers cannot race the token manager. +- `glpi_python_client._async.clients.api.*` contains the contract-aligned endpoint mixins, grouped by GLPI subtree (administration, assistance, assistance/timeline, dropdowns, management, knowledgebase, plugins). - Most are synchronous; `knowledgebase/_article_async.py` and - `plugins/_fields_async.py` are hand-written async overrides needed + Each mixin is written once, in the async tree. (Historically some had + hand-written async overrides needed because their synchronous bodies call a sibling public method through `self` (see the `clients.custom` entry below for the other reason a method needs one). -- `glpi_python_client.clients.custom` contains custom helpers built on +- `glpi_python_client._async.clients.custom` contains custom helpers built on top of the API mixins. Each helper has a synchronous implementation (`_ticket_context.py`, `_statistics.py`) plus an async override (`_ticket_context_async.py`, `_statistics_async.py`) that fans the @@ -78,7 +77,7 @@ python -m pytest other — a synchronous body calling a sibling public method through `self` — is why `clients.api.knowledgebase` and `clients.api.plugins` also ship one (see above). -- `glpi_python_client.auth._v1_session` contains the legacy v1 +- `glpi_python_client._async.auth._v1_session` contains the legacy v1 session used for binary document uploads. - `glpi_python_client.models` contains typed request and response models. @@ -96,20 +95,21 @@ python -m pytest ## Adding Endpoints 1. Add or extend a model in `glpi_python_client.models`. -2. Add the client method on the matching **synchronous** endpoint mixin - under `glpi_python_client.clients.api.*` (or - `glpi_python_client.clients.custom.*` for derived helpers). The - async client picks the new method up automatically through the - `AsyncBridge` — do not duplicate the method on a parallel async - mixin unless you genuinely need concurrent fan-out (`asyncio.gather`) - inside the method body, **or** the method calls a sibling public - method through `self` (directly, or transitively via a private - helper): the bridge wraps that sibling into a coroutine, so the - un-awaited call silently drops instead of running. The guard in step - 4 fails the suite if you miss this. +2. Add the client method on the matching endpoint mixin under + `glpi_python_client/_async/clients/api/**` (or `.../custom/**` for + derived helpers), as an `async def`. Then run `python + unasync_build.py` to regenerate `glpi_python_client/_sync/`, and + commit both. **Never edit `_sync/` by hand** — CI regenerates it and + fails on any difference. + + Write the method once. There is no parallel async mixin to keep in + step: if it needs concurrent fan-out, call `gather` from + `glpi_python_client/_async/_concurrency.py`, which runs the calls + concurrently on the async surface and sequentially on the generated + one, from the same source line. 3. Put reusable endpoint names, payload builders, response handling, or pagination logic in the focused - `glpi_python_client.clients.commons` helper module named for that + `glpi_python_client._async.clients.commons` helper module named for that responsibility. 4. Add unit tests for payload serialization, response parsing, and client behavior. The parity test in diff --git a/docs/development_rtd.rst b/docs/development_rtd.rst index 6b7e838..84931b4 100644 --- a/docs/development_rtd.rst +++ b/docs/development_rtd.rst @@ -116,21 +116,21 @@ Package Layout Public import surface (``GlpiClient``, ``GlpiTicketContext``, public Pydantic models, enums, and ``__version__``). -``glpi_python_client.clients.glpi_client`` +``glpi_python_client._async.clients.client`` Composition root. ``GlpiClient`` mixes the per-resource async API mixins, the OAuth2 token manager, the asynchronous v2 transport, and the optional internal v1 session used for document uploads. -``glpi_python_client.clients.api`` +``glpi_python_client._async.clients.api`` Async API mixins generated from the GLPI v2 OpenAPI contract: tickets, ticket timeline (followups, tasks, solutions, documents), team members, documents, users, locations, entities, ... -``glpi_python_client.clients.custom`` +``glpi_python_client._async.clients.custom`` Higher-level helpers built on top of the contract mixins: ``get_ticket_context``, ``get_ticket_statistics``, ``get_task_statistics``. -``glpi_python_client.clients.commons`` +``glpi_python_client._async.clients.commons`` Shared HTTP transport pieces, including the timeline envelope unwrap that reconciles live server behaviour with the OpenAPI contract. @@ -147,11 +147,11 @@ Adding Endpoints #. Add or extend the contract-aligned models in ``glpi_python_client.models.api_schema``. -#. Add the async mixin and method under ``glpi_python_client.clients.api``, +#. Add the async mixin and method under ``glpi_python_client._async.clients.api``, mirroring the OpenAPI path and HTTP verb. #. When the live server diverges from the contract, document the choice in the module docstring and (when needed) wire an unwrap helper from - ``glpi_python_client.clients.commons``. + ``glpi_python_client._async.clients.commons``. #. Re-export new public symbols from ``glpi_python_client.__init__``. #. Add tests for payload serialization, response parsing, and client behaviour. #. Document the workflow in :doc:`user_guide` and the matching skill in diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 96ae23c..b064c5c 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -164,62 +164,41 @@ When to pick which event loop (for example a FastAPI or aiohttp service, an async CLI, or a Jupyter notebook cell), or when you want concurrent fan-out. -How the async client is implemented -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The asynchronous surface is a thin facade over the synchronous -endpoint mixins. The -:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` -base class walks the MRO of :class:`AsyncGlpiClient` at class-creation -time and wraps every inherited public synchronous method into a -coroutine wrapper that schedules the call on a worker thread: - -* by default through :func:`asyncio.to_thread`; -* on a caller-supplied :class:`concurrent.futures.Executor` when one is - passed to the constructor or to ``from_env``. - -Because the underlying HTTP layer is still backed by the blocking -:class:`httpx.Client`, every concurrent worker runs on a distinct -thread. A shared :class:`threading.Lock` (not :class:`asyncio.Lock`) -serialises OAuth token acquisition so concurrent ``asyncio.gather`` -fan-outs cannot race the auth manager, while the HTTP requests -themselves execute outside the lock through the thread-safe -:class:`httpx.Client`. - -A number of helpers ship with hand-written async overrides rather than -relying solely on the bridge. There are two reasons a method needs its -own async variant: - -1. **Concurrency** — the method benefits from fanning multiple GLPI - calls out concurrently with :func:`asyncio.gather`. -2. **Internal self-calls** — the method calls another public method - through ``self`` (e.g. ``self.search_tickets(...)`` inside a - pagination loop). When the bridge runs the synchronous body in a - worker thread, ``self.method`` resolves to the bridge-wrapped - *coroutine*, which returns a coroutine object instead of data when - called without ``await``. The async override replaces the body so - every internal call is properly awaited on the event loop. - -Helpers with async overrides: - -* :meth:`AsyncGlpiClient.get_ticket_context` — fans the five underlying - GLPI calls out concurrently (reason: concurrency). -* :meth:`AsyncGlpiClient.get_task_statistics` — fans the per-ticket - task-list calls out concurrently (reason: concurrency). -* :meth:`AsyncGlpiClient.get_task_durations` — fans the per-ticket task - fetches out concurrently when ``return_task_details=True``, and - properly awaits ``iter_search_tickets`` and ``search_entities`` - internally (reasons: concurrency + internal self-calls). -* :meth:`AsyncGlpiClient.get_ticket_statistics` — properly awaits - ``search_tickets`` and ``search_entities`` internally (reason: - internal self-calls). -* :meth:`AsyncGlpiClient.get_user_activity` — properly awaits - ``search_users``, ``iter_search_tickets``, and ``get_task_durations`` - internally (reason: internal self-calls). -* ``iter_search_tickets``, ``iter_search_users``, - ``iter_search_entities`` — each pagination loop body calls - ``self.search_*(...)``; the async variants are native async generators - that ``await`` those calls directly (reason: internal self-calls). +How the two clients stay in step +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Both clients are the same code. The asynchronous tree is written by hand +and the synchronous one is *generated* from it: a build step strips +``async``/``await`` and renames the handful of tokens that differ between +the two surfaces. The generated tree is committed, and CI regenerates it +and fails on any difference, so the two cannot drift apart. + +This is why the endpoint surfaces are identical and why a fix never has +to be applied twice. It also means neither client is a wrapper around the +other: :class:`AsyncGlpiClient` performs real non-blocking I/O on the +event loop, and :class:`GlpiClient` performs real blocking I/O with no +thread pool, no executor, and no coroutine scheduling. + +Exactly one module is maintained separately for each surface, because the +two need genuinely different primitives rather than differently-spelled +ones: + +* **Fan-out.** Aggregating helpers such as + :meth:`AsyncGlpiClient.get_ticket_context` issue several GLPI calls + through a shared ``gather`` helper. On the async surface that is + :func:`asyncio.gather` and the calls overlap; on the sync surface the + arguments have already been evaluated by the time ``gather`` is + entered, so the same expression means "one after the other". The + calling code is identical. +* **The auth lock.** :class:`AsyncGlpiClient` uses an + :class:`asyncio.Lock` and :class:`GlpiClient` a + :class:`threading.Lock`. Neither substitutes for the other. A + :class:`threading.Lock` on the event loop would be held across an + ``await``, so a second task waiting on it would block the loop and the + task holding it could never resume to release it. An + :class:`asyncio.Lock` in the sync client would bind itself to whichever + event loop first contended it, breaking the guarantee that one + :class:`GlpiClient` may be shared across threads. Pagination helpers (``iter_search_tickets``, ``iter_search_users``, ``iter_search_entities``) are exposed as **async generators** on the diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index d3c0b66..eecafaa 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -14,6 +14,7 @@ from __future__ import annotations +from glpi_python_client._async.clients import AsyncGlpiClient from glpi_python_client._errors import ( GlpiAuthError, GlpiError, @@ -25,7 +26,7 @@ GlpiTransportError, GlpiValidationError, ) -from glpi_python_client.clients import AsyncGlpiClient, GlpiClient +from glpi_python_client._sync.clients import GlpiClient from glpi_python_client.models import ( DeleteDocument, DeleteEntity, diff --git a/glpi_python_client/auth/__init__.py b/glpi_python_client/_async/auth/__init__.py similarity index 56% rename from glpi_python_client/auth/__init__.py rename to glpi_python_client/_async/auth/__init__.py index 98746fe..d4731b6 100644 --- a/glpi_python_client/auth/__init__.py +++ b/glpi_python_client/_async/auth/__init__.py @@ -1,13 +1,13 @@ """Public authentication exports for the GLPI client package. The authentication package owns the OAuth2 token manager used by the -asynchronous ``GlpiClient`` and the legacy v1 session wrapper used solely by +GLPI client and the legacy v1 session wrapper used solely by the management document upload mixin. """ from __future__ import annotations -from glpi_python_client.auth._v1_session import GLPIV1Session -from glpi_python_client.auth.auth import GLPITokenManager +from glpi_python_client._async.auth._v1_session import GLPIV1Session +from glpi_python_client._async.auth.auth import GLPITokenManager __all__ = ["GLPITokenManager", "GLPIV1Session"] diff --git a/glpi_python_client/_async/auth/_v1_session.py b/glpi_python_client/_async/auth/_v1_session.py new file mode 100644 index 0000000..a80f788 --- /dev/null +++ b/glpi_python_client/_async/auth/_v1_session.py @@ -0,0 +1,466 @@ +"""GLPI v1 REST session used for legacy endpoints not exposed by v2. + +Two consumers currently share this session: + +* the management :class:`DocumentMixin` for the multipart + ``POST /Document`` upload (the v2 API does not advertise a binary + upload route), and +* the :class:`PluginFieldsMixin` for the GLPI "Fields" plugin endpoints + (``PluginFieldsContainer``, ``PluginFieldsField`` and the per-item + value itemtypes), which the v2 contract does not surface at all. + +The session wrapper owns the authenticated v1 lifecycle (init, refresh, +kill) and exposes the typed ``upload_document`` helper plus the generic +``request_json`` JSON-only HTTP helper that newer mixins build on. + +Retry policy +------------ +Every public dispatch helper (``_init_session``, ``request_json``, +``upload_document``) carries the same :mod:`tenacity` retry decorator +used by the v2 transport: three attempts spaced by three seconds, +triggered by :class:`~glpi_python_client.GlpiTransportError` (network +faults) and +:class:`~glpi_python_client.GlpiServerError` (which +:func:`finalize_request_response` raises for 5xx server errors), with +``reraise=True`` so the real error surfaces once retries are exhausted. +Not every :class:`ValueError` subclass is retried, only the one named in +the predicate above: :class:`~glpi_python_client.GlpiServerError` (5xx) +*is* a ``ValueError`` and *is* retried by this decorator. +:class:`~glpi_python_client.GlpiStatusError` subclasses for 4xx statuses, +:class:`~glpi_python_client.GlpiValidationError`, and +:class:`~glpi_python_client.GlpiProtocolError` are also ``ValueError`` +subclasses but are not in the retry predicate, so they surface +immediately without a retry. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, cast + +import httpx +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed + +from glpi_python_client._async.clients.commons._config import build_http_session +from glpi_python_client._async.clients.commons._http import ( + ensure_response_status, + finalize_request_response, + response_json_or_empty, + transport_error_from, +) +from glpi_python_client._errors import ( + GlpiProtocolError, + GlpiServerError, + GlpiTransportError, + GlpiValidationError, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS = 15 * 60 +_AUTH_FAILURE_STATUS_CODES = frozenset({401, 403}) +#: Retry policy for the v1 session, expressed in library-owned types. +#: +#: Mirrors the v2 transport policy deliberately: naming the HTTP library's +#: own exception base here would make the retries stop matching — silently — +#: the next time the transport changes. +_RETRY_ON_NETWORK_ERRORS = retry( + retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)), + stop=stop_after_attempt(3), + wait=wait_fixed(3), + reraise=True, +) + + +class GLPIV1Session: + """Authenticated GLPI v1 REST session limited to document upload. + + The session takes care of token initialisation, periodic refresh, retry on + auth failure, and best-effort cleanup. Only the upload endpoint is exposed + because the rest of the high-level client uses the v2 API exclusively. + """ + + def __init__( + self, + *, + base_url: str, + user_token: str, + app_token: str | None = None, + verify_ssl: bool = True, + session_refresh_interval_seconds: int = ( + _DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS + ), + ) -> None: + self._base_url = base_url.rstrip("/") + self._user_token = user_token + self._app_token = app_token + if session_refresh_interval_seconds < 1: + raise GlpiValidationError( + "session_refresh_interval_seconds must be a positive integer" + ) + self._session_refresh_interval = timedelta( + seconds=session_refresh_interval_seconds + ) + + # Built through the shared factory so the SSL policy is applied at + # construction: httpx reads ``verify`` only in ``Client.__init__`` + # and silently ignores a later assignment. + self._http = build_http_session(verify_ssl=verify_ssl) + + self._session_token: str | None = None + self._session_started_at: datetime | None = None + + async def _dispatch(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + """Send one raw v1 HTTP call, translating transport faults. + + Every call this session makes goes through here so network failures + surface as :class:`~glpi_python_client.GlpiTransportError` rather than + as the HTTP library's own exception type. That is what lets the retry + predicate above name a library-owned type, and it keeps callers from + having to import the HTTP library to catch a connection failure. + + Raises + ------ + GlpiTransportError + When the request never produced a response. + """ + + try: + return await self._http.request(method.upper(), url, **kwargs) + except httpx.HTTPError as exc: + raise transport_error_from(exc, method=method, url=url) from exc + + @_RETRY_ON_NETWORK_ERRORS + async def _init_session(self) -> None: + """Acquire one fresh GLPI v1 session token via ``GET /initSession``. + + The call replaces any existing session state and stores the + authentication timestamp used by the refresh-interval check. + Network errors and 5xx responses are retried; 4xx and payload + errors propagate immediately as :class:`ValueError`. + """ + + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"user_token {self._user_token}", + } + if self._app_token: + headers["App-Token"] = self._app_token + + url = f"{self._base_url}/initSession" + response = await self._dispatch("GET", url, headers=headers, timeout=30) + finalize_request_response( + response, + method="get", + url=url, + success_statuses=(200,), + logger=logger, + ) + ensure_response_status( + response, + success_statuses=(200,), + failure_message="GLPI v1 initSession failed", + ) + + token = response.json().get("session_token") + if not token: + raise GlpiProtocolError("GLPI v1 initSession returned no session_token") + + self._session_token = str(token) + self._session_started_at = datetime.now(tz=timezone.utc) + logger.info("GLPI v1 session initialised.") + + async def _ensure_session(self) -> None: + """Lazily initialise or renew the v1 session when the token is stale. + + The helper is called by every authenticated request before any header + construction so callers never have to manage the lifecycle directly. + """ + + if self._session_token is None: + await self._init_session() + return + if self._is_session_stale(): + logger.info("GLPI v1 session reached refresh interval; renewing session.") + await self._renew_session() + + def _is_session_stale(self) -> bool: + """Return whether the current v1 session token must be renewed. + + Stale tokens are determined by the configured refresh interval relative + to the timestamp the current token was acquired. + """ + + if self._session_started_at is None: + return True + return datetime.now(tz=timezone.utc) >= ( + self._session_started_at + self._session_refresh_interval + ) + + def _session_headers(self) -> dict[str, str]: + """Return the GLPI v1 headers carrying the current session token. + + The helper assumes ``_ensure_session`` has already been called so the + session token state is valid. + """ + + headers: dict[str, str] = { + "Session-Token": str(self._session_token), + "Accept": "application/json", + } + if self._app_token: + headers["App-Token"] = self._app_token + return headers + + async def _renew_session(self) -> None: + """Drop the current GLPI v1 session token and acquire a new one. + + The previous token is best-effort killed so the GLPI server can release + the associated session state immediately. ``_init_session`` will set + the new token on success or raise, leaving the existing state + untouched on failure (the retry decorator handles transients). + """ + + if self._session_token is not None: + try: + await self._dispatch( + "GET", + f"{self._base_url}/killSession", + headers=self._session_headers(), + timeout=10, + ) + except Exception: + logger.warning("Failed to kill stale GLPI v1 session.", exc_info=True) + await self._init_session() + + async def _headers(self) -> dict[str, str]: + """Return ready-to-use authenticated GLPI v1 request headers. + + The helper is the single header entry-point used by all authenticated + v1 calls so token lifecycle handling stays centralised. + """ + + await self._ensure_session() + return self._session_headers() + + async def _authenticated_request( + self, + method: str, + url: str, + *, + success_statuses: tuple[int, ...], + headers: dict[str, str] | None = None, + **kwargs: Any, + ) -> httpx.Response: + """Send one authenticated GLPI v1 request and finalize the response. + + When the GLPI server rejects the current token the helper renews + the session and retries the request once. The returned response + has already been passed through :func:`finalize_request_response` + so 5xx errors surface as + :class:`~glpi_python_client.GlpiServerError` for the outer + tenacity retry to catch; non-success statuses outside the + ``success_statuses`` set are logged but otherwise returned for + the caller to validate with :func:`ensure_response_status`. + """ + + request_headers = {**await self._headers(), **(headers or {})} + # Dispatch through ``request(method, ...)`` rather than looking up a + # per-verb attribute: it is the one call shape both transports share, + # and it keeps the verb a value instead of an attribute name. + verb = method.upper() + response = await self._dispatch(verb, url, headers=request_headers, **kwargs) + if _is_auth_failure_response(response): + logger.warning( + "GLPI v1 session token was rejected; refreshing session and " + "retrying request once." + ) + await self._renew_session() + request_headers = {**await self._headers(), **(headers or {})} + response = await self._dispatch( + verb, url, headers=request_headers, **kwargs + ) + return finalize_request_response( + response, + method=method, + url=url, + success_statuses=success_statuses, + logger=logger, + ) + + async def close(self) -> None: + """Kill the v1 session token and release the underlying HTTP session. + + Cleanup is best-effort: any failure during ``killSession`` is logged + and the local session state is still cleared. + """ + + try: + if self._session_token is not None: + await self._dispatch( + "GET", + f"{self._base_url}/killSession", + headers=self._session_headers(), + timeout=10, + ) + logger.info("GLPI v1 session killed.") + except Exception: + logger.warning("Failed to kill GLPI v1 session.", exc_info=True) + finally: + self._session_token = None + self._session_started_at = None + await self._http.aclose() + + @_RETRY_ON_NETWORK_ERRORS + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + """Send one JSON-only authenticated request to the GLPI v1 API. + + The helper centralises session-token handling, the one-shot retry + on token rejection, status validation and JSON parsing so callers + can stay focused on their endpoint semantics. Network errors and + 5xx responses are retried; 4xx and payload errors propagate + immediately as :class:`ValueError`. + + Parameters + ---------- + method : str + HTTP verb (``"GET"``, ``"POST"``, ``"PUT"``, ``"DELETE"``). + path : str + Resource path appended to the v1 base URL (without leading + slash, e.g. ``"PluginFieldsContainer"``). + params : dict[str, object] | None, optional + Query-string parameters forwarded to the HTTP transport. + json_body : dict[str, object] | None, optional + JSON body serialised into the request when set. The + ``Content-Type: application/json`` header is added + automatically. + success_statuses : tuple[int, ...], optional + HTTP status codes considered successful (default covers the + CRUD codes returned by the v1 API). + failure_message : str | None, optional + Prefix used in the :class:`~glpi_python_client.GlpiStatusError` + raised on a non-success status. Defaults to ``"GLPI v1 + {METHOD} {path} failed"``. + + Returns + ------- + object + Parsed JSON body for non-empty responses; an empty ``dict`` + when the body is empty or contains only whitespace. + + Raises + ------ + GlpiStatusError + If the v1 server returns a non-success HTTP status outside + the 5xx range (narrows to :class:`~glpi_python_client.GlpiAuthError` + or :class:`~glpi_python_client.GlpiNotFoundError` where the + status allows it). Inherits from ``ValueError``. + GlpiServerError + If the v1 server persistently returns a 5xx status after this + decorator's retries are exhausted. + """ + + url = f"{self._base_url}/{path.lstrip('/')}" + kwargs: dict[str, Any] = {"timeout": 30} + if params is not None: + kwargs["params"] = params + headers: dict[str, str] = {} + if json_body is not None: + kwargs["content"] = json.dumps(json_body) + headers["Content-Type"] = "application/json" + response = await self._authenticated_request( + method, + url, + success_statuses=success_statuses, + headers=headers or None, + **kwargs, + ) + ensure_response_status( + response, + success_statuses=success_statuses, + failure_message=failure_message + or f"GLPI v1 {method.upper()} {path} failed", + ) + return response_json_or_empty(response) + + @_RETRY_ON_NETWORK_ERRORS + async def upload_document( + self, + filename: str, + content: bytes, + mime_type: str, + *, + document_name: str | None = None, + ticket_id: int | None = None, + entity_id: int | None = None, + ) -> dict[str, object]: + """Upload one binary document via ``POST /Document``. + + The legacy v1 endpoint uses a multipart upload manifest so the GLPI + server can create the document, link it to the optional parent ticket, + and assign it to the provided entity in a single round-trip. Network + errors and 5xx responses are retried; 4xx and payload errors + propagate immediately as :class:`ValueError`. + """ + + manifest_input: dict[str, object] = { + "name": document_name or filename, + "_filename": [filename], + } + if entity_id is not None: + manifest_input["entities_id"] = int(entity_id) + if ticket_id is not None: + manifest_input["itemtype"] = "Ticket" + manifest_input["items_id"] = int(ticket_id) + manifest_input["tickets_id"] = int(ticket_id) + manifest = json.dumps({"input": manifest_input}) + response = await self._authenticated_request( + "POST", + f"{self._base_url}/Document", + success_statuses=(200, 201), + files=[ + ("uploadManifest", (None, manifest, "application/json")), + ("filename[]", (filename, content, mime_type)), + ], + timeout=60, + ) + ensure_response_status( + response, + success_statuses=(200, 201), + failure_message="GLPI v1 document upload failed", + ) + payload = response.json() + if not isinstance(payload, dict): + raise GlpiProtocolError( + "GLPI v1 document upload returned unexpected payload: " + f"{type(payload).__name__}" + ) + logger.info("GLPI v1 document uploaded: id=%s", payload.get("id")) + return cast(dict[str, object], payload) + + +def _is_auth_failure_response(response: httpx.Response) -> bool: + """Return whether one GLPI v1 response means the session token is invalid. + + Both HTTP-level rejection and the ``ERROR_SESSION_TOKEN_INVALID`` payload + marker emitted by the GLPI v1 API are considered auth failures. + """ + + if response.status_code in _AUTH_FAILURE_STATUS_CODES: + return True + return "ERROR_SESSION_TOKEN_INVALID" in str(response.text or "") + + +__all__ = ["GLPIV1Session"] diff --git a/glpi_python_client/_async/auth/auth.py b/glpi_python_client/_async/auth/auth.py new file mode 100644 index 0000000..2ae6840 --- /dev/null +++ b/glpi_python_client/_async/auth/auth.py @@ -0,0 +1,416 @@ +"""OAuth2 token management for the high-level GLPI clients. + +The token manager centralizes credential validation, token acquisition, +refresh behavior, and lifecycle cleanup so both sync and async clients can +share the same authenticated session state. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +import httpx +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed + +from glpi_python_client._async.clients.commons._config import build_http_session +from glpi_python_client._async.clients.commons._http import transport_error_from +from glpi_python_client._errors import ( + GlpiServerError, + GlpiTransportError, + GlpiValidationError, + status_error_class, +) + +logger = logging.getLogger(__name__) + + +def validate_credentials( + *, + client_id: str | None, + client_secret: str | None, + username: str | None, + password: str | None, +) -> None: + """Check that the supplied OAuth credential sets are complete. + + GLPI authentication accepts client credentials, user credentials, or + both. Partial pairs are rejected here so the token request path does not + fail later with a less actionable error. + + This is a free function rather than only a method so callers can check a + configuration *before* committing resources to it. That ordering matters: + the client used to build its HTTP session first and unwind it in an + ``except`` clause when validation failed, which is not expressible on the + async surface -- an ``httpx.AsyncClient`` has no synchronous close, and a + constructor cannot await one. Validating up front removes the need to + unwind anything. + + Raises + ------ + GlpiValidationError + If either pair is half-supplied, or if neither pair is supplied. + """ + + missing_client_fields = [ + name + for name, value in { + "client_id": client_id, + "client_secret": client_secret, + }.items() + if value is None + ] + missing_user_fields = [ + name + for name, value in { + "username": username, + "password": password, + }.items() + if value is None + ] + + has_client_fields = len(missing_client_fields) < 2 + has_user_fields = len(missing_user_fields) < 2 + + if has_client_fields and missing_client_fields: + raise GlpiValidationError( + "GLPI OAuth client credentials must include both client_id " + "and client_secret." + ) + if has_user_fields and missing_user_fields: + raise GlpiValidationError( + "GLPI user credentials must include both username and password." + ) + if not (client_id is not None and client_secret is not None) and not ( + username is not None and password is not None + ): + raise GlpiValidationError( + "GLPI authentication requires either client_id/client_secret, " + "username/password, or both." + ) + + +class GLPITokenManager: + """OAuth2 token manager for the GLPI API. + + Parameters + ---------- + token_url : str + Full URL of the GLPI token endpoint. + client_id : str | None, optional + OAuth2 client ID. Provide it together with ``client_secret`` when the + GLPI instance requires client authentication. + client_secret : str | None, optional + OAuth2 client secret. Provide it together with ``client_id``. + username : str | None, optional + Username for the password grant flow. Provide it together with + ``password``. + password : str | None, optional + Password for the password grant flow. Provide it together with + ``username``. + session : httpx.AsyncClient | None, optional + Existing HTTP client to reuse. + auth_token_refresh : int | None, optional + Maximum token age in seconds before a refresh is attempted. ``None`` + disables interval-based refreshes. + """ + + def __init__( + self, + token_url: str, + client_id: str | None = None, + client_secret: str | None = None, + username: str | None = None, + password: str | None = None, + session: httpx.AsyncClient | None = None, + auth_token_refresh: int | None = None, + ) -> None: + self._token_url = token_url + self._client_id = client_id + self._client_secret = client_secret + self._username = username + self._password = password + self._owns_session = session is None + self._session = session or build_http_session(verify_ssl=True) + self._auth_token_refresh_interval = _refresh_interval(auth_token_refresh) + + self._validate_credentials() + + self.access_token: str | None = None + self.refresh_token: str | None = None + self.token_expires_at: datetime | None = None + self.token_updated_at: datetime | None = None + + async def _post_token_request(self, data: dict[str, str]) -> httpx.Response: + """POST the OAuth token endpoint, translating transport faults. + + Network failures surface as + :class:`~glpi_python_client.GlpiTransportError` so the retry + predicates below can name a library-owned type and callers never have + to import the HTTP library to catch a connection failure. + + Raises + ------ + GlpiTransportError + When the token request never produced a response. + """ + + try: + return await self._session.post(self._token_url, data=data, timeout=30) + except httpx.HTTPError as exc: + raise transport_error_from(exc, method="post", url=self._token_url) from exc + + @property + def auth_token_refresh(self) -> int | None: + """Return the proactive refresh delay configured for this manager. + + The public property keeps the original integer value used at + construction time instead of exposing the internal ``timedelta`` + representation. + """ + + if self._auth_token_refresh_interval is None: + return None + return int(self._auth_token_refresh_interval.total_seconds()) + + def _validate_credentials(self) -> None: + """Validate that the configured OAuth credential sets are complete.""" + + validate_credentials( + client_id=self._client_id, + client_secret=self._client_secret, + username=self._username, + password=self._password, + ) + + @property + def _has_client_credentials(self) -> bool: + return self._client_id is not None and self._client_secret is not None + + @property + def _has_user_credentials(self) -> bool: + return self._username is not None and self._password is not None + + def _build_token_request_data(self) -> dict[str, str]: + """Build the form payload sent to the OAuth token endpoint. + + The payload shape depends on whether the manager is using the password + grant or pure client-credentials flow, and it includes client + credentials when that pair is configured. + """ + + data: dict[str, str] = {"scope": "api"} + if self._has_user_credentials: + assert self._username is not None + assert self._password is not None + data["grant_type"] = "password" + data["username"] = self._username + data["password"] = self._password + else: + data["grant_type"] = "client_credentials" + + if self._has_client_credentials: + assert self._client_id is not None + assert self._client_secret is not None + data["client_id"] = self._client_id + data["client_secret"] = self._client_secret + + return data + + def _store_token_data( + self, token_data: dict[str, object], label: str = "acquired" + ) -> None: + """Store token data from an OAuth2 response. + + Parameters + ---------- + token_data : dict[str, object] + Token endpoint JSON response. + label : str, optional + Label for log messages. + + Returns + ------- + None + Mutates instance state. + """ + + self.access_token = str(token_data.get("access_token") or "") or None + refresh_token = str(token_data.get("refresh_token") or "").strip() + if refresh_token: + self.refresh_token = refresh_token + expires_in = int(str(token_data.get("expires_in") or 3600)) + now = datetime.now(tz=timezone.utc) + self.token_updated_at = now + self.token_expires_at = now + timedelta(seconds=expires_in) + logger.info("GLPI OAuth token %s successfully.", label) + + def logout(self) -> None: + """Discard the currently cached OAuth tokens. + + Returns + ------- + None + Clears in-memory token state. + """ + + self.access_token = None + self.refresh_token = None + self.token_expires_at = None + self.token_updated_at = None + + async def close(self) -> None: + """Release token-manager resources. + + Returns + ------- + None + Performs a local logout and closes any owned HTTP session. + """ + + self.logout() + if self._owns_session: + await self._session.aclose() + + def _should_refresh_by_interval(self, now: datetime) -> bool: + """Return whether the configured proactive refresh interval elapsed. + + This check supplements token-expiry handling so long-lived clients can + refresh credentials before the server-side expiry timestamp is reached. + """ + + if self._auth_token_refresh_interval is None or self.token_updated_at is None: + return False + return now >= self.token_updated_at + self._auth_token_refresh_interval + + @retry( + retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)), + stop=stop_after_attempt(3), + wait=wait_fixed(3), + reraise=True, + ) + async def _acquire_token(self) -> None: + """Acquire an OAuth2 access token using the configured auth flow. + + Returns + ------- + None + Stores the new access token. + + Raises + ------ + GlpiAuthError + If GLPI rejects the credentials (401/403). Not retried. + GlpiServerError + If the token endpoint fails (5xx). Retried up to 3 attempts. + GlpiStatusError + If the token endpoint returns any other unexpected status. Not + retried. + """ + + data = self._build_token_request_data() + response = await self._post_token_request(data) + if 200 <= response.status_code < 300: + self._store_token_data(response.json()) + return + try: + error_detail = response.json() + except Exception: + error_detail = response.text + error_class = status_error_class(response.status_code) + raise error_class( + f"GLPI OAuth token returned {response.status_code}: {error_detail}", + status_code=response.status_code, + url=self._token_url, + response_text=str(error_detail), + ) + + @retry( + retry=retry_if_exception_type(GlpiTransportError), + stop=stop_after_attempt(3), + wait=wait_fixed(3), + reraise=True, + ) + async def _refresh_access_token(self) -> None: + """Refresh the OAuth2 access token using the stored refresh token. + + Returns + ------- + None + Stores the refreshed token or acquires a new one. + + Raises + ------ + GlpiAuthError + If GLPI rejects the credentials while refreshing (401/403). This + method does not raise directly on a non-2xx response: it logs a + warning and falls through to a nested :meth:`_acquire_token` + call, which raises. That nested call is not retried by either + decorator, so a persistent 401 costs 1 refresh POST + 1 acquire + POST (2 total) before this propagates. + GlpiServerError + If the token endpoint fails (5xx) while refreshing. This + method's own retry decorator only matches + ``GlpiTransportError`` (network-level faults), not + ``GlpiServerError``, so it does not retry the fall-through to + :meth:`_acquire_token`. The nested call carries its own + independent decorator, which does retry ``GlpiServerError`` up + to 3 attempts. A persistent 5xx therefore costs exactly 1 + refresh POST + 3 nested acquire POSTs = 4 POST requests, not the + 12 an earlier, less precise predicate produced by retrying the + already-retried nested failure a second time. + GlpiStatusError + If the token endpoint returns any other unexpected status while + refreshing, raised by the nested :meth:`_acquire_token` call. + Not retried. + """ + + if not self.refresh_token: + await self._acquire_token() + return + + data = { + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + } + if self._has_client_credentials: + assert self._client_id is not None + assert self._client_secret is not None + data["client_id"] = self._client_id + data["client_secret"] = self._client_secret + response = await self._post_token_request(data) + if 200 <= response.status_code < 300: + self._store_token_data(response.json(), label="refreshed") + return + logger.warning("Token refresh failed, acquiring new token...") + await self._acquire_token() + + async def ensure_token(self) -> None: + """Ensure a valid OAuth2 access token is available. + + Returns + ------- + None + Updates token state when needed. + """ + + if not self.access_token: + await self._acquire_token() + return + + now = datetime.now(tz=timezone.utc) + token_expired = ( + self.token_expires_at is not None and now >= self.token_expires_at + ) + if token_expired or self._should_refresh_by_interval(now): + await self._refresh_access_token() + + +def _refresh_interval(value: int | None) -> timedelta | None: + if value is None: + return None + if value < 1: + raise GlpiValidationError( + "auth_token_refresh must be a positive integer or None" + ) + return timedelta(seconds=value) diff --git a/glpi_python_client/_async/clients/__init__.py b/glpi_python_client/_async/clients/__init__.py new file mode 100644 index 0000000..ada209a --- /dev/null +++ b/glpi_python_client/_async/clients/__init__.py @@ -0,0 +1,17 @@ +"""Client class for one GLPI surface. + +The concrete client composes every per-endpoint mixin from +:mod:`glpi_python_client._async.clients.api`, the aggregated helpers from +:mod:`glpi_python_client._async.clients.custom`, and the transport mixin +from :mod:`glpi_python_client._async.clients.commons`. + +Only one of the two client trees is written by hand; the other is +generated from it. Both expose the same endpoint surface, so the choice +between them is purely about the caller's runtime model. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.client import AsyncGlpiClient + +__all__ = ["AsyncGlpiClient"] diff --git a/glpi_python_client/clients/_base_client.py b/glpi_python_client/_async/clients/_base_client.py similarity index 90% rename from glpi_python_client/clients/_base_client.py rename to glpi_python_client/_async/clients/_base_client.py index 5febafb..785e3b8 100644 --- a/glpi_python_client/clients/_base_client.py +++ b/glpi_python_client/_async/clients/_base_client.py @@ -1,9 +1,9 @@ -"""Shared construction logic for the synchronous and asynchronous clients. +"""Shared construction logic for the GLPI client. The :class:`_BaseGlpiClient` mixin holds the constructor signature, the resource-bundle assignment, and the :meth:`from_env` classmethod that -both :class:`~glpi_python_client.clients.sync_client.GlpiClient` and -:class:`~glpi_python_client.clients.async_client.AsyncGlpiClient` use. +both :class:`~glpi_python_client._async.clients.sync_client.GlpiClient` and +:class:`~glpi_python_client._async.clients.async_client.AsyncGlpiClient` use. Lifecycle helpers (``close``, ``__enter__``/``__exit__`` versus ``__aenter__``/``__aexit__``) stay on the concrete subclasses because they differ between the sync and async surfaces. @@ -14,7 +14,6 @@ import logging import os import sys -import threading from typing import TYPE_CHECKING if sys.version_info >= (3, 11): @@ -22,7 +21,8 @@ else: # pragma: no cover - fallback for Python 3.10 from typing_extensions import Self -from glpi_python_client.clients.commons._config import ( +from glpi_python_client._async._concurrency import Lock +from glpi_python_client._async.clients.commons._config import ( build_client_env_config, build_client_resources, ) @@ -37,8 +37,8 @@ class _BaseGlpiClient: """Shared construction helpers for the GLPI client variants. The mixin assigns the resource bundle returned by - :func:`build_client_resources` and the header/lock/state attributes - used by :class:`~glpi_python_client.clients.commons._transport.TransportMixin`. + :func:`build_client_resources` and the header, lock and state + attributes the transport mixin declares. """ def __init__( @@ -124,7 +124,7 @@ def __init__( self.glpi_profile = glpi_profile self.entity_recursive = entity_recursive self.language = language - self._auth_lock = threading.Lock() + self._auth_lock = Lock() self._closed = False @classmethod @@ -152,9 +152,8 @@ def from_env( prefix : str, optional Common prefix shared by every environment variable name. **overrides : object - Keyword overrides forwarded to :meth:`__init__`. The - asynchronous client accepts an additional ``executor`` - keyword here. + Keyword overrides forwarded to :meth:`__init__`; the + keyword overrides are forwarded verbatim. Returns ------- diff --git a/glpi_python_client/_async/clients/api/__init__.py b/glpi_python_client/_async/clients/api/__init__.py new file mode 100644 index 0000000..7bd032e --- /dev/null +++ b/glpi_python_client/_async/clients/api/__init__.py @@ -0,0 +1,54 @@ +"""Per-endpoint API mixins backed by the ``api_schema`` Pydantic models. + +The mixins under this package mirror the endpoints documented in +``docs/glpi_api_contract.json`` one for one. They wrap the +transport helpers from :mod:`glpi_python_client._async.clients.commons` and exchange +typed ``Get``, ``Post``, ``Patch``, and ``Delete`` +models with the GLPI API. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.administration import ( + EntityMixin, + UserMixin, +) +from glpi_python_client._async.clients.api.assistance import ( + TeamMemberMixin, + TicketMixin, +) +from glpi_python_client._async.clients.api.assistance.timeline import ( + FollowupMixin, + SolutionMixin, + TicketTaskMixin, + TimelineDocumentMixin, +) +from glpi_python_client._async.clients.api.dropdowns import LocationMixin +from glpi_python_client._async.clients.api.knowledgebase import ( + KBArticleCommentMixin, + KBArticleMixin, + KBArticleRevisionMixin, + KBCategoryMixin, +) +from glpi_python_client._async.clients.api.management import DocumentMixin +from glpi_python_client._async.clients.api.plugins import ( + PluginFieldsMixin, +) + +__all__ = [ + "DocumentMixin", + "EntityMixin", + "FollowupMixin", + "KBArticleCommentMixin", + "KBArticleMixin", + "KBArticleRevisionMixin", + "KBCategoryMixin", + "LocationMixin", + "PluginFieldsMixin", + "SolutionMixin", + "TeamMemberMixin", + "TicketMixin", + "TicketTaskMixin", + "TimelineDocumentMixin", + "UserMixin", +] diff --git a/glpi_python_client/_async/clients/api/administration/__init__.py b/glpi_python_client/_async/clients/api/administration/__init__.py new file mode 100644 index 0000000..228b403 --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/__init__.py @@ -0,0 +1,13 @@ +"""GLPI ``/Administration`` mixins for the GLPI client. + +The submodules expose the user and entity mixins used by +:class:`glpi_python_client._async.clients.sync_client.GlpiClient` and +:class:`glpi_python_client._async.clients.async_client.AsyncGlpiClient`. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.administration._entity import EntityMixin +from glpi_python_client._async.clients.api.administration._user import UserMixin + +__all__ = ["EntityMixin", "UserMixin"] diff --git a/glpi_python_client/_async/clients/api/administration/_entity.py b/glpi_python_client/_async/clients/api/administration/_entity.py new file mode 100644 index 0000000..0a4242e --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/_entity.py @@ -0,0 +1,217 @@ +"""GLPI ``/Administration/Entity`` mixin. + +The mixin exposes the search, fetch, create, update, and delete helpers +for the GLPI entity resource. Entity calls intentionally bypass the +client's ``GLPI-Entity`` header so cross-entity lookups remain possible. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import ENTITY_ENDPOINT, GlpiId +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.administration._entity import ( + DeleteEntity, + GetEntity, + PatchEntity, + PostEntity, +) + + +class EntityMixin(TransportMixin): + """CRUD helpers for ``/Administration/Entity``.""" + + async def search_entities( + self, + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[GetEntity]: + """Search GLPI entities with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int | None, optional + Maximum number of records returned. ``None`` lets the GLPI + server use its default. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetEntity] + Entities matching the filter. + """ + + params: dict[str, object] = {"start": start} + if limit is not None: + params["limit"] = limit + if rsql_filter: + params["filter"] = rsql_filter + return await self._resource_list( + ENTITY_ENDPOINT, GetEntity, params=params, skip_entity=True + ) + + async def iter_search_entities( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> AsyncIterator[list[GetEntity]]: + """Yield successive pages of GLPI entities until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + Entity calls bypass the ``GLPI-Entity`` header so cross-entity + lookups remain possible. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every accessible entity. + batch_size : int, optional + Number of records requested per page (default 50). + + Yields + ------ + list[GetEntity] + One page of entities per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_entities( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_entity(self, entity_id: GlpiId) -> GetEntity: + """Fetch one GLPI entity by identifier. + + Parameters + ---------- + entity_id : GlpiId + Numeric identifier of the entity to retrieve. + + Returns + ------- + GetEntity + Validated entity payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{ENTITY_ENDPOINT}/{entity_id}", + GetEntity, + failure_message=f"Failed to get entity {entity_id}", + skip_entity=True, + ) + + async def create_entity(self, entity: PostEntity) -> int: + """Create one GLPI entity. + + Parameters + ---------- + entity : PostEntity + Request body describing the entity to create. + + Returns + ------- + int + Identifier assigned by the GLPI server. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + ENTITY_ENDPOINT, + entity, + failure_message="Failed to create entity", + missing_message="GLPI entity create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created entity {new_id}", + skip_entity=True, + ) + + async def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None: + """Update one GLPI entity with a partial body. + + Parameters + ---------- + entity_id : GlpiId + Numeric identifier of the entity to update. + entity : PatchEntity + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{ENTITY_ENDPOINT}/{entity_id}", + entity, + failure_message=f"Failed to update entity {entity_id}", + log_message=f"GLPI API updated entity {entity_id}", + ) + + async def delete_entity( + self, entity_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI entity by identifier. + + Parameters + ---------- + entity_id : GlpiId + Numeric identifier of the entity to delete. + force : bool | None, optional + When ``True`` the entity is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{ENTITY_ENDPOINT}/{entity_id}", + failure_message=f"Failed to delete entity {entity_id}", + log_message=f"GLPI API deleted entity {entity_id}", + force=force, + delete_model_cls=DeleteEntity, + skip_entity=True, + ) + + +__all__ = ["EntityMixin"] diff --git a/glpi_python_client/_async/clients/api/administration/_user.py b/glpi_python_client/_async/clients/api/administration/_user.py new file mode 100644 index 0000000..57e6fbc --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/_user.py @@ -0,0 +1,224 @@ +"""GLPI ``/Administration/User`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI user resource. All operations exchange the +:mod:`glpi_python_client.models.api_schema.administration` models and rely on +the transport mixin for HTTP dispatch. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import USER_ENDPOINT, GlpiId +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.administration._user import ( + DeleteUser, + GetUser, + PatchUser, + PostUser, +) + + +class UserMixin(TransportMixin): + """CRUD helpers for ``/Administration/User``. + + The helpers follow the contract-first naming convention and forward all + server-side validation to the GLPI API instead of duplicating checks on + the client side. + """ + + async def search_users( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + """Search GLPI users with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter, + for example ``"username==alice"``. Empty by default. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Returns + ------- + list[GetUser] + Users matching the filter, validated against the contract + ``User`` schema. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return await self._resource_list( + USER_ENDPOINT, GetUser, params=params, skip_entity=skip_entity + ) + + async def iter_search_users( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + skip_entity: bool = False, + ) -> AsyncIterator[list[GetUser]]: + """Yield successive pages of GLPI users until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible user. + batch_size : int, optional + Number of records requested per page (default 50). + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Yields + ------ + list[GetUser] + One page of users per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_users( + rsql_filter, + limit=batch_size, + start=start, + skip_entity=skip_entity, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_user(self, user_id: GlpiId) -> GetUser: + """Fetch one GLPI user by identifier. + + Parameters + ---------- + user_id : GlpiId + Numeric identifier of the user to retrieve. + + Returns + ------- + GetUser + Validated user payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{USER_ENDPOINT}/{user_id}", + GetUser, + failure_message=f"Failed to get user {user_id}", + ) + + async def create_user(self, user: PostUser) -> int: + """Create one GLPI user. + + Parameters + ---------- + user : PostUser + Request body describing the user to create. + + Returns + ------- + int + Identifier assigned by the GLPI server. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + USER_ENDPOINT, + user, + failure_message="Failed to create user", + missing_message="GLPI user create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created user {new_id}", + ) + + async def update_user(self, user_id: GlpiId, user: PatchUser) -> None: + """Update one GLPI user with a partial body. + + Parameters + ---------- + user_id : GlpiId + Numeric identifier of the user to update. + user : PatchUser + Partial request body. Only fields explicitly set are sent. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{USER_ENDPOINT}/{user_id}", + user, + failure_message=f"Failed to update user {user_id}", + log_message=f"GLPI API updated user {user_id}", + ) + + async def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> None: + """Delete one GLPI user by identifier. + + Parameters + ---------- + user_id : GlpiId + Numeric identifier of the user to delete. + force : bool | None, optional + When ``True`` the user is permanently deleted instead of + being moved to the trash. ``None`` omits the query parameter. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{USER_ENDPOINT}/{user_id}", + failure_message=f"Failed to delete user {user_id}", + log_message=f"GLPI API deleted user {user_id}", + force=force, + delete_model_cls=DeleteUser, + ) + + +__all__ = ["UserMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/__init__.py b/glpi_python_client/_async/clients/api/assistance/__init__.py new file mode 100644 index 0000000..f7dfdea --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/__init__.py @@ -0,0 +1,8 @@ +"""GLPI ``/Assistance`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.assistance._team import TeamMemberMixin +from glpi_python_client._async.clients.api.assistance._ticket import TicketMixin + +__all__ = ["TeamMemberMixin", "TicketMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/_team.py b/glpi_python_client/_async/clients/api/assistance/_team.py new file mode 100644 index 0000000..1d52375 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/_team.py @@ -0,0 +1,121 @@ +"""GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin. + +The team-member endpoint exposes list, add, and remove operations on a +ticket. The mixin uses the ``api_schema`` ``TeamMember`` models and lets +the GLPI API perform server-side validation of role and itemtype values. +""" + +from __future__ import annotations + +import logging + +from glpi_python_client._async.clients.commons._constants import ( + TEAM_MEMBER_SUFFIX, + TICKET_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._http import ensure_response_status +from glpi_python_client._async.clients.commons._payloads import model_to_payload +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assistance._team import ( + GetTeamMember, + PostTeamMember, +) + +logger = logging.getLogger(__name__) + + +class TeamMemberMixin(TransportMixin): + """Helpers for the ticket team-member endpoint.""" + + async def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]: + """List the team members currently linked to one ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket whose team members are listed. + + Returns + ------- + list[GetTeamMember] + Team members validated against the contract ``TeamMember`` schema. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_list( + f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}", + GetTeamMember, + failure_message=f"Failed to list ticket team members for {ticket_id}", + ) + + async def add_ticket_team_member( + self, ticket_id: GlpiId, member: PostTeamMember + ) -> None: + """Add one team member to a ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket to receive the new member. + member : PostTeamMember + Request body describing the member. The ``id`` field is the + target user/group/supplier identifier, ``type`` is one of + ``"User"``, ``"Group"`` or ``"Supplier"``, and ``role`` is one + of ``"requester"``, ``"assigned"`` or ``"observer"``. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}" + response = await self._post_request(endpoint, model_to_payload(member)) + ensure_response_status( + response, + success_statuses=(200, 201), + failure_message=f"Failed to add team member on ticket {ticket_id}", + ) + logger.info("GLPI API added team member on ticket %s", ticket_id) + + async def remove_ticket_team_member( + self, ticket_id: GlpiId, member: PostTeamMember + ) -> None: + """Remove one team member from a ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket the member belongs to. + member : PostTeamMember + Request body identifying the member to remove (same shape as + :meth:`add_ticket_team_member`). + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}", + failure_message=f"Failed to remove team member on ticket {ticket_id}", + log_message=f"GLPI API removed team member on ticket {ticket_id}", + body=model_to_payload(member), + ) + + +__all__ = ["TeamMemberMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/_ticket.py b/glpi_python_client/_async/clients/api/assistance/_ticket.py new file mode 100644 index 0000000..e080333 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/_ticket.py @@ -0,0 +1,238 @@ +"""GLPI ``/Assistance/Ticket`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI ticket resource using the ``api_schema`` Pydantic models. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import TICKET_ENDPOINT, GlpiId +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assistance._ticket import ( + DeleteTicket, + GetTicket, + PatchTicket, + PostTicket, +) + + +class TicketMixin(TransportMixin): + """CRUD helpers for ``/Assistance/Ticket``. + + The helpers exchange the contract-aligned ``GetTicket``, ``PostTicket``, + ``PatchTicket``, and ``DeleteTicket`` models with the GLPI API and let the + server perform all field-level validation. + """ + + async def search_tickets( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[GetTicket]: + """Search GLPI tickets with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter + (for example ``"name==hello"`` or ``"status==2"``). Empty by + default, which lists every visible ticket. + limit : int, optional + Maximum number of records returned by the GLPI server in one + request. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + ``sort`` query parameter forwarded as-is, e.g. ``"date_mod desc"``. + fields : tuple[str, ...], optional + Restricted set of contract field names to request. Empty + tuple lets the GLPI server pick its default field set. + + Returns + ------- + list[GetTicket] + Tickets matching the filter, validated against the contract + ``Ticket`` schema. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort: + params["sort"] = sort + if fields: + params["fields"] = ",".join(fields) + return await self._resource_list(TICKET_ENDPOINT, GetTicket, params=params) + + async def iter_search_tickets( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> AsyncIterator[list[GetTicket]]: + """Yield successive pages of GLPI tickets until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible ticket. + batch_size : int, optional + Number of records requested per page (default 50). Acts as + the ``limit`` parameter on each underlying + :meth:`search_tickets` call. + sort : str | None, optional + ``sort`` query parameter forwarded as-is to each page request. + fields : tuple[str, ...], optional + Restricted set of contract field names to request. + + Yields + ------ + list[GetTicket] + One page of tickets per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_tickets( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + fields=fields, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_ticket(self, ticket_id: GlpiId) -> GetTicket: + """Fetch one GLPI ticket by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket to retrieve. + + Returns + ------- + GetTicket + Validated ticket payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{TICKET_ENDPOINT}/{ticket_id}", + GetTicket, + failure_message=f"Failed to get ticket {ticket_id}", + ) + + async def create_ticket(self, ticket: PostTicket) -> int: + """Create one GLPI ticket. + + Parameters + ---------- + ticket : PostTicket + Request body describing the ticket to create. Any extra + fields are forwarded verbatim through ``extra_payload``. + + Returns + ------- + int + Identifier assigned by the GLPI server to the new ticket. + + Raises + ------ + GlpiStatusError + If the HTTP status is not success. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + TICKET_ENDPOINT, + ticket, + failure_message="Failed to create ticket", + missing_message="GLPI ticket create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created ticket {new_id}", + ) + + async def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None: + """Update one GLPI ticket with a partial body. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket to update. + ticket : PatchTicket + Partial request body. Only fields explicitly set are sent. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{TICKET_ENDPOINT}/{ticket_id}", + ticket, + failure_message=f"Failed to update ticket {ticket_id}", + log_message=f"GLPI API updated ticket {ticket_id}", + ) + + async def delete_ticket( + self, ticket_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI ticket by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket to delete. + force : bool | None, optional + When ``True`` the GLPI server permanently removes the ticket + instead of moving it to the trash. ``None`` omits the query + parameter and uses the GLPI default behaviour. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{TICKET_ENDPOINT}/{ticket_id}", + failure_message=f"Failed to delete ticket {ticket_id}", + log_message=f"GLPI API deleted ticket {ticket_id}", + force=force, + delete_model_cls=DeleteTicket, + ) + + +__all__ = ["TicketMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py b/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py new file mode 100644 index 0000000..b518046 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py @@ -0,0 +1,23 @@ +"""GLPI ticket-timeline mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.assistance.timeline._document import ( + TimelineDocumentMixin, +) +from glpi_python_client._async.clients.api.assistance.timeline._followup import ( + FollowupMixin, +) +from glpi_python_client._async.clients.api.assistance.timeline._solution import ( + SolutionMixin, +) +from glpi_python_client._async.clients.api.assistance.timeline._task import ( + TicketTaskMixin, +) + +__all__ = [ + "FollowupMixin", + "SolutionMixin", + "TicketTaskMixin", + "TimelineDocumentMixin", +] diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_document.py b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py new file mode 100644 index 0000000..d832174 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py @@ -0,0 +1,225 @@ +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin. + +The mixin exposes list, fetch, link, and unlink helpers for the timeline +document endpoint that links existing GLPI documents to a ticket. + +Notes +----- +The live GLPI v2 server returns each entry of the list endpoint wrapped +in a ``{"type": "Document_Item", "item": {...}}`` envelope, even though +the OpenAPI contract documents a flat array of ``Document_Item``. The +``item`` value is a full ``Document`` record (matching :class:`GetDocument`), +not a ``Document_Item`` link record — real behaviour wins over the contract. +:func:`list_ticket_timeline_documents` unwraps the envelope through the shared +``TransportMixin._resource_list`` helper and deserialises each inner object +as :class:`GetDocument`. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + TICKET_ENDPOINT, + TIMELINE_DOCUMENT_SUFFIX, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assistance.timeline._document import ( + DeleteTimelineDocument, + PatchTimelineDocument, + PostTimelineDocument, +) +from glpi_python_client.models.api_schema.management._document import GetDocument + + +class TimelineDocumentMixin(TransportMixin): + """CRUD helpers for the ticket document timeline endpoint.""" + + async def list_ticket_timeline_documents( + self, ticket_id: GlpiId + ) -> list[GetDocument]: + """List all documents linked to one ticket timeline. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + + Returns + ------- + list[GetDocument] + Document records returned by the GLPI server. The live API + wraps each entry in a ``{"type": "Document_Item", "item": {...}}`` + envelope whose ``item`` value is a full ``Document`` record; the + envelope is unwrapped automatically. + """ + + return await self._resource_list( + f"{TICKET_ENDPOINT}/{ticket_id}/{TIMELINE_DOCUMENT_SUFFIX}", + GetDocument, + failure_message=( + f"Failed to list timeline documents for ticket {ticket_id}" + ), + unwrap_envelope=True, + ) + + async def get_ticket_timeline_document( + self, ticket_id: GlpiId, document_link_id: GlpiId + ) -> GetDocument: + """Fetch one document linked to the ticket timeline by its document ID. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + document_link_id : GlpiId + Numeric identifier of the linked document to retrieve. + + Returns + ------- + GetDocument + Validated document payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{TICKET_ENDPOINT}/{ticket_id}/" + f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}", + GetDocument, + failure_message=( + f"Failed to get timeline document {document_link_id} on " + f"ticket {ticket_id}" + ), + ) + + async def link_ticket_timeline_document( + self, ticket_id: GlpiId, document_link: PostTimelineDocument + ) -> int: + """Link an existing GLPI document to one ticket timeline. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + document_link : PostTimelineDocument + Request body describing the link (typically the timeline + position; document and ticket identifiers are inferred from + the URL). + + Returns + ------- + int + Identifier assigned by the GLPI server to the new link. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + f"{TICKET_ENDPOINT}/{ticket_id}/{TIMELINE_DOCUMENT_SUFFIX}", + document_link, + failure_message=(f"Failed to link timeline document on ticket {ticket_id}"), + missing_message=( + "GLPI timeline document link response did not include an ID" + ), + log_message_factory=( + lambda new_id: ( + f"GLPI API linked timeline document {new_id} on ticket {ticket_id}" + ) + ), + ) + + async def update_ticket_timeline_document( + self, + ticket_id: GlpiId, + document_link_id: GlpiId, + document_link: PatchTimelineDocument, + ) -> None: + """Update one timeline document link with a partial body. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + document_link_id : GlpiId + Numeric identifier of the timeline document link to update. + document_link : PatchTimelineDocument + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{TICKET_ENDPOINT}/{ticket_id}/" + f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}", + document_link, + failure_message=( + f"Failed to update timeline document {document_link_id} on " + f"ticket {ticket_id}" + ), + log_message=( + f"GLPI API updated timeline document {document_link_id} on " + f"ticket {ticket_id}" + ), + ) + + async def unlink_ticket_timeline_document( + self, + ticket_id: GlpiId, + document_link_id: GlpiId, + *, + force: bool | None = None, + ) -> None: + """Unlink one timeline document from a ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + document_link_id : GlpiId + Numeric identifier of the timeline document link to remove. + force : bool | None, optional + When ``True`` the link is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{TICKET_ENDPOINT}/{ticket_id}/" + f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}", + failure_message=( + f"Failed to unlink timeline document {document_link_id} on " + f"ticket {ticket_id}" + ), + log_message=( + f"GLPI API unlinked timeline document {document_link_id} on " + f"ticket {ticket_id}" + ), + force=force, + delete_model_cls=DeleteTimelineDocument, + ) + + +__all__ = ["TimelineDocumentMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py b/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py new file mode 100644 index 0000000..a50b591 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py @@ -0,0 +1,204 @@ +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin. + +The mixin exposes list, fetch, create, update, and delete helpers for the +ticket followup timeline endpoint, exchanging the ``api_schema`` followup +models with the GLPI API. + +Notes +----- +The live GLPI v2 server returns each entry of the list endpoint wrapped +in a ``{"type": "ITILFollowup", "item": {...}}`` envelope, even though +the OpenAPI contract documents a flat array of ``ITILFollowup``. Real +behaviour wins over the contract, so :func:`list_ticket_followups` +unwraps the envelope via the shared +:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` +helper and tolerates both shapes. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + FOLLOWUP_SUFFIX, + TICKET_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assistance.timeline._followup import ( + DeleteFollowup, + GetFollowup, + PatchFollowup, + PostFollowup, +) + + +class FollowupMixin(TransportMixin): + """CRUD helpers for the ticket followup timeline endpoint.""" + + async def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]: + """List all followups linked to one ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + + Returns + ------- + list[GetFollowup] + Followups returned by the GLPI server, with the timeline + envelope unwrapped where present. + """ + + return await self._resource_list( + f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}", + GetFollowup, + failure_message=f"Failed to list followups for ticket {ticket_id}", + unwrap_envelope=True, + ) + + async def get_ticket_followup( + self, ticket_id: GlpiId, followup_id: GlpiId + ) -> GetFollowup: + """Fetch one ticket followup by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + followup_id : GlpiId + Numeric identifier of the followup to retrieve. + + Returns + ------- + GetFollowup + Validated followup payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}", + GetFollowup, + failure_message=( + f"Failed to get followup {followup_id} on ticket {ticket_id}" + ), + ) + + async def create_ticket_followup( + self, ticket_id: GlpiId, followup: PostFollowup + ) -> int: + """Create one followup on a ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + followup : PostFollowup + Request body describing the followup to create. + + Returns + ------- + int + Identifier assigned by the GLPI server to the new followup. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}", + followup, + failure_message=f"Failed to create followup on ticket {ticket_id}", + missing_message="GLPI followup create response did not include an ID", + id_keys=("id", "followup_id"), + log_message_factory=( + lambda new_id: ( + f"GLPI API created followup {new_id} on ticket {ticket_id}" + ) + ), + ) + + async def update_ticket_followup( + self, + ticket_id: GlpiId, + followup_id: GlpiId, + followup: PatchFollowup, + ) -> None: + """Update one ticket followup with a partial body. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + followup_id : GlpiId + Numeric identifier of the followup to update. + followup : PatchFollowup + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}", + followup, + failure_message=( + f"Failed to update followup {followup_id} on ticket {ticket_id}" + ), + log_message=f"API updated followup {followup_id} on ticket {ticket_id}", + ) + + async def delete_ticket_followup( + self, + ticket_id: GlpiId, + followup_id: GlpiId, + *, + force: bool | None = None, + ) -> None: + """Delete one ticket followup by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + followup_id : GlpiId + Numeric identifier of the followup to delete. + force : bool | None, optional + When ``True`` the followup is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}", + failure_message=( + f"Failed to delete followup {followup_id} on ticket {ticket_id}" + ), + log_message=f"API deleted followup {followup_id} on ticket {ticket_id}", + force=force, + delete_model_cls=DeleteFollowup, + ) + + +__all__ = ["FollowupMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py b/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py new file mode 100644 index 0000000..116d82a --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py @@ -0,0 +1,201 @@ +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin. + +The mixin exposes list, fetch, create, update, and delete helpers for the +ticket solution timeline endpoint using the ``api_schema`` solution models. + +Notes +----- +The live GLPI v2 server returns each entry of the list endpoint wrapped +in a ``{"type": "ITILSolution", "item": {...}}`` envelope, even though +the OpenAPI contract documents a flat array of ``ITILSolution``. Real +behaviour wins over the contract, so :func:`list_ticket_solutions` +unwraps the envelope through the shared +:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` +helper and tolerates both shapes. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + SOLUTION_SUFFIX, + TICKET_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assistance.timeline._solution import ( + DeleteSolution, + GetSolution, + PatchSolution, + PostSolution, +) + + +class SolutionMixin(TransportMixin): + """CRUD helpers for the ticket solution timeline endpoint.""" + + async def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]: + """List all solutions linked to one ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + + Returns + ------- + list[GetSolution] + Solutions returned by the GLPI server, with the timeline + envelope unwrapped where present. + """ + + return await self._resource_list( + f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}", + GetSolution, + failure_message=f"Failed to list solutions for ticket {ticket_id}", + unwrap_envelope=True, + ) + + async def get_ticket_solution( + self, ticket_id: GlpiId, solution_id: GlpiId + ) -> GetSolution: + """Fetch one ticket solution by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + solution_id : GlpiId + Numeric identifier of the solution to retrieve. + + Returns + ------- + GetSolution + Validated solution payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}", + GetSolution, + failure_message=( + f"Failed to get solution {solution_id} on ticket {ticket_id}" + ), + ) + + async def create_ticket_solution( + self, ticket_id: GlpiId, solution: PostSolution + ) -> int: + """Create one solution on a ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + solution : PostSolution + Request body describing the solution to create. + + Returns + ------- + int + Identifier assigned by the GLPI server to the new solution. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}", + solution, + failure_message=f"Failed to create solution on ticket {ticket_id}", + missing_message="GLPI solution create response did not include an ID", + id_keys=("id", "solution_id"), + log_message_factory=( + lambda new_id: f"API created solution {new_id} on ticket {ticket_id}" + ), + ) + + async def update_ticket_solution( + self, + ticket_id: GlpiId, + solution_id: GlpiId, + solution: PatchSolution, + ) -> None: + """Update one ticket solution with a partial body. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + solution_id : GlpiId + Numeric identifier of the solution to update. + solution : PatchSolution + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}", + solution, + failure_message=( + f"Failed to update solution {solution_id} on ticket {ticket_id}" + ), + log_message=f"API updated solution {solution_id} on ticket {ticket_id}", + ) + + async def delete_ticket_solution( + self, + ticket_id: GlpiId, + solution_id: GlpiId, + *, + force: bool | None = None, + ) -> None: + """Delete one ticket solution by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + solution_id : GlpiId + Numeric identifier of the solution to delete. + force : bool | None, optional + When ``True`` the solution is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}", + failure_message=( + f"Failed to delete solution {solution_id} on ticket {ticket_id}" + ), + log_message=f"API deleted solution {solution_id} on ticket {ticket_id}", + force=force, + delete_model_cls=DeleteSolution, + ) + + +__all__ = ["SolutionMixin"] diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_task.py b/glpi_python_client/_async/clients/api/assistance/timeline/_task.py new file mode 100644 index 0000000..ccfe011 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_task.py @@ -0,0 +1,194 @@ +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin. + +The mixin exposes list, fetch, create, update, and delete helpers for the +ticket task timeline endpoint using the contract-aligned ``api_schema`` +ticket-task models. + +Notes +----- +The live GLPI v2 server returns each entry of the list endpoint wrapped +in a ``{"type": "Task", "item": {...}}`` envelope, even though the +OpenAPI contract documents a flat array of ``TicketTask``. Real behaviour +wins over the contract, so :func:`list_ticket_tasks` unwraps the envelope +through the shared +:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` +helper and tolerates both shapes. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + TASK_SUFFIX, + TICKET_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assistance.timeline._task import ( + DeleteTicketTask, + GetTicketTask, + PatchTicketTask, + PostTicketTask, +) + + +class TicketTaskMixin(TransportMixin): + """CRUD helpers for the ticket task timeline endpoint.""" + + async def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]: + """List all tasks linked to one ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + + Returns + ------- + list[GetTicketTask] + Tasks returned by the GLPI server, with the timeline envelope + unwrapped where present. + """ + + return await self._resource_list( + f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}", + GetTicketTask, + failure_message=f"Failed to list tasks for ticket {ticket_id}", + unwrap_envelope=True, + ) + + async def get_ticket_task( + self, ticket_id: GlpiId, task_id: GlpiId + ) -> GetTicketTask: + """Fetch one ticket task by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + task_id : GlpiId + Numeric identifier of the task to retrieve. + + Returns + ------- + GetTicketTask + Validated task payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}/{task_id}", + GetTicketTask, + failure_message=f"Failed to get task {task_id} on ticket {ticket_id}", + ) + + async def create_ticket_task(self, ticket_id: GlpiId, task: PostTicketTask) -> int: + """Create one task on a ticket. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + task : PostTicketTask + Request body describing the task to create. + + Returns + ------- + int + Identifier assigned by the GLPI server to the new task. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}", + task, + failure_message=f"Failed to create task on ticket {ticket_id}", + missing_message="GLPI task create response did not include an ID", + id_keys=("id", "task_id"), + log_message_factory=( + lambda new_id: f"GLPI API created task {new_id} on ticket {ticket_id}" + ), + ) + + async def update_ticket_task( + self, + ticket_id: GlpiId, + task_id: GlpiId, + task: PatchTicketTask, + ) -> None: + """Update one ticket task with a partial body. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + task_id : GlpiId + Numeric identifier of the task to update. + task : PatchTicketTask + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}/{task_id}", + task, + failure_message=f"Failed to update task {task_id} on ticket {ticket_id}", + log_message=f"GLPI API updated task {task_id} on ticket {ticket_id}", + ) + + async def delete_ticket_task( + self, + ticket_id: GlpiId, + task_id: GlpiId, + *, + force: bool | None = None, + ) -> None: + """Delete one ticket task by identifier. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + task_id : GlpiId + Numeric identifier of the task to delete. + force : bool | None, optional + When ``True`` the task is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}/{task_id}", + failure_message=f"Failed to delete task {task_id} on ticket {ticket_id}", + log_message=f"GLPI API deleted task {task_id} on ticket {ticket_id}", + force=force, + delete_model_cls=DeleteTicketTask, + ) + + +__all__ = ["TicketTaskMixin"] diff --git a/glpi_python_client/_async/clients/api/dropdowns/__init__.py b/glpi_python_client/_async/clients/api/dropdowns/__init__.py new file mode 100644 index 0000000..60704f7 --- /dev/null +++ b/glpi_python_client/_async/clients/api/dropdowns/__init__.py @@ -0,0 +1,7 @@ +"""GLPI ``/Dropdowns`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.dropdowns._location import LocationMixin + +__all__ = ["LocationMixin"] diff --git a/glpi_python_client/_async/clients/api/dropdowns/_location.py b/glpi_python_client/_async/clients/api/dropdowns/_location.py new file mode 100644 index 0000000..8cc9916 --- /dev/null +++ b/glpi_python_client/_async/clients/api/dropdowns/_location.py @@ -0,0 +1,170 @@ +"""GLPI ``/Dropdowns/Location`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI location dropdown resource using the contract-aligned ``api_schema`` +models. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + LOCATION_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.dropdowns._location import ( + DeleteLocation, + GetLocation, + PatchLocation, + PostLocation, +) + + +class LocationMixin(TransportMixin): + """CRUD helpers for ``/Dropdowns/Location``.""" + + async def search_locations( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + ) -> list[GetLocation]: + """Search GLPI locations with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetLocation] + Locations matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return await self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params) + + async def get_location(self, location_id: GlpiId) -> GetLocation: + """Fetch one GLPI location by identifier. + + Parameters + ---------- + location_id : GlpiId + Numeric identifier of the location to retrieve. + + Returns + ------- + GetLocation + Validated location payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{LOCATION_ENDPOINT}/{location_id}", + GetLocation, + failure_message=f"Failed to get location {location_id}", + ) + + async def create_location(self, location: PostLocation) -> int: + """Create one GLPI location. + + Parameters + ---------- + location : PostLocation + Request body describing the location to create. + + Returns + ------- + int + Identifier assigned by the GLPI server. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + LOCATION_ENDPOINT, + location, + failure_message="Failed to create location", + missing_message="GLPI location create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created location {new_id}", + ) + + async def update_location( + self, location_id: GlpiId, location: PatchLocation + ) -> None: + """Update one GLPI location with a partial body. + + Parameters + ---------- + location_id : GlpiId + Numeric identifier of the location to update. + location : PatchLocation + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{LOCATION_ENDPOINT}/{location_id}", + location, + failure_message=f"Failed to update location {location_id}", + log_message=f"GLPI API updated location {location_id}", + ) + + async def delete_location( + self, location_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI location by identifier. + + Parameters + ---------- + location_id : GlpiId + Numeric identifier of the location to delete. + force : bool | None, optional + When ``True`` the location is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{LOCATION_ENDPOINT}/{location_id}", + failure_message=f"Failed to delete location {location_id}", + log_message=f"GLPI API deleted location {location_id}", + force=force, + delete_model_cls=DeleteLocation, + ) + + +__all__ = ["LocationMixin"] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/__init__.py b/glpi_python_client/_async/clients/api/knowledgebase/__init__.py new file mode 100644 index 0000000..74b679a --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/__init__.py @@ -0,0 +1,21 @@ +"""GLPI ``/Knowledgebase`` mixins.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.knowledgebase._article import KBArticleMixin +from glpi_python_client._async.clients.api.knowledgebase._category import ( + KBCategoryMixin, +) +from glpi_python_client._async.clients.api.knowledgebase._comment import ( + KBArticleCommentMixin, +) +from glpi_python_client._async.clients.api.knowledgebase._revision import ( + KBArticleRevisionMixin, +) + +__all__ = [ + "KBArticleCommentMixin", + "KBArticleMixin", + "KBArticleRevisionMixin", + "KBCategoryMixin", +] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_article.py b/glpi_python_client/_async/clients/api/knowledgebase/_article.py new file mode 100644 index 0000000..19dac76 --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/_article.py @@ -0,0 +1,214 @@ +"""GLPI ``/Knowledgebase/Article`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI knowledge base article resource using the contract-aligned +``api_schema`` models. Article ``content`` and ``description`` round-trip +Markdown through GLPI's HTML wire format transparently. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from glpi_python_client._async.clients.commons._constants import ( + KB_ARTICLE_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError +from glpi_python_client.models.api_schema._common import IdNameRef +from glpi_python_client.models.api_schema.knowledgebase._article import ( + DeleteKBArticle, + GetKBArticle, + PatchKBArticle, + PostKBArticle, +) + +_V1_CATEGORY_FEATURE_LABEL = "knowledge base category assignments" + + +class KBArticleMixin(TransportMixin): + """CRUD helpers for ``/Knowledgebase/Article``.""" + + async def search_kb_articles( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBArticle]: + """Search GLPI knowledge base articles with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + ``sort`` query parameter forwarded as-is. + language : str | None, optional + GLPI language code forwarded as the ``language`` query + parameter to select a translated view. + + Returns + ------- + list[GetKBArticle] + Articles matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort: + params["sort"] = sort + if language: + params["language"] = language + return await self._resource_list( + KB_ARTICLE_ENDPOINT, GetKBArticle, params=params + ) + + async def get_kb_article(self, article_id: GlpiId) -> GetKBArticle: + """Fetch one knowledge base article by identifier. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{KB_ARTICLE_ENDPOINT}/{article_id}", + GetKBArticle, + failure_message=f"Failed to get KB article {article_id}", + ) + + async def create_kb_article(self, article: PostKBArticle) -> int: + """Create one knowledge base article and return its new identifier. + + When ``article.categories`` is a non-empty list, the categories are + applied through the legacy fallback (:meth:`set_kb_article_categories`) + because the v2 API cannot write them; this requires a configured v1 + session. ``None`` or an empty list is skipped — a freshly created + article has no categories to clear — so callers that omit categories + never need a v1 session. The v2 create is not undone if the category + assignment fails: the article already exists, so the failure raises a + ``RuntimeError`` naming the new article id (chaining the original + error) and leaves the article in place for you to re-assign categories. + """ + + new_id = await self._resource_create( + KB_ARTICLE_ENDPOINT, + article, + failure_message="Failed to create KB article", + missing_message="GLPI KB article create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created KB article {new_id}", + ) + # A new article has no categories to clear, so only a non-empty list + # triggers the legacy fallback; ``None``/``[]`` are no-ops here. + if article.categories: + try: + await self._apply_category_fallback(new_id, article.categories) + except Exception as exc: + raise RuntimeError( + f"KB article {new_id} was created but assigning its " + f"categories failed: {exc}" + ) from exc + return new_id + + async def update_kb_article( + self, article_id: GlpiId, article: PatchKBArticle + ) -> None: + """Update one knowledge base article with a partial body. + + When ``article.categories`` is provided — including an empty list to + clear every category — the categories are applied through the legacy + fallback (:meth:`set_kb_article_categories`) after the v2 patch, because + the v2 API cannot write them; this requires a configured v1 session. + ``None`` (the default) leaves categories untouched. Unlike create, + update is not rolled back on a category failure — the v2 field changes + are already applied. + """ + + await self._resource_update( + f"{KB_ARTICLE_ENDPOINT}/{article_id}", + article, + failure_message=f"Failed to update KB article {article_id}", + log_message=f"GLPI API updated KB article {article_id}", + ) + await self._apply_category_fallback(article_id, article.categories) + + async def delete_kb_article( + self, article_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one knowledge base article by identifier.""" + + await self._resource_delete( + f"{KB_ARTICLE_ENDPOINT}/{article_id}", + failure_message=f"Failed to delete KB article {article_id}", + log_message=f"GLPI API deleted KB article {article_id}", + force=force, + delete_model_cls=DeleteKBArticle, + ) + + async def set_kb_article_categories( + self, article_id: GlpiId, category_ids: Sequence[int] + ) -> None: + """Set the categories of one knowledge base article. + + GLPI 11 stores KB categories as a many-to-many relationship that the + v2 API exposes as read-only (``KBArticle.categories[].id`` is + ``readOnly``), so it silently drops category writes. This helper sets + the underlying ``_categories`` field through the legacy v1 API, which + requires ``v1_base_url``/``v1_user_token`` to be configured (pointing + at the legacy ``apirest.php``). + + The supplied ids REPLACE the article's full category set; passing an + empty sequence clears every category. Category ids are not validated + against the server — an unknown id simply is not linked. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + """ + + v1 = self._require_v1_session(_V1_CATEGORY_FEATURE_LABEL) + await v1.request_json( + "PUT", + f"KnowbaseItem/{article_id}", + json_body={"input": {"_categories": [int(c) for c in category_ids]}}, + failure_message=f"Failed to set categories on KB article {article_id}", + ) + + async def _apply_category_fallback( + self, article_id: GlpiId, categories: list[IdNameRef] | None + ) -> None: + """Apply ``categories`` through the legacy fallback when provided. + + No-op when ``categories`` is ``None``. An empty list clears every + category (used by update); ``create_kb_article`` skips the empty case + before calling this helper. Raises ``GlpiValidationError`` when a + category reference lacks an ``id``. + """ + + if categories is None: + return + ids: list[int] = [] + for ref in categories: + if ref.id is None: + raise GlpiValidationError( + "KB article categories require an 'id' to be linked; got a " + "category reference without an id." + ) + ids.append(ref.id) + await self.set_kb_article_categories(article_id, ids) + + +__all__ = ["KBArticleMixin"] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_category.py b/glpi_python_client/_async/clients/api/knowledgebase/_category.py new file mode 100644 index 0000000..c1a063b --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/_category.py @@ -0,0 +1,120 @@ +"""GLPI ``/Knowledgebase/Category`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI knowledge base category resource using the contract-aligned +``api_schema`` models. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + KB_CATEGORY_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.knowledgebase._category import ( + DeleteKBCategory, + GetKBCategory, + PatchKBCategory, + PostKBCategory, +) + + +class KBCategoryMixin(TransportMixin): + """CRUD helpers for ``/Knowledgebase/Category``.""" + + async def search_kb_categories( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + language: str | None = None, + ) -> list[GetKBCategory]: + """Search GLPI knowledge base categories with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + ``sort`` query parameter forwarded as-is, e.g. ``"name asc"``. + language : str | None, optional + GLPI language code forwarded as the ``language`` query + parameter to select a translated view. + + Returns + ------- + list[GetKBCategory] + Categories matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort: + params["sort"] = sort + if language: + params["language"] = language + return await self._resource_list( + KB_CATEGORY_ENDPOINT, GetKBCategory, params=params + ) + + async def get_kb_category(self, category_id: GlpiId) -> GetKBCategory: + """Fetch one knowledge base category by identifier. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{KB_CATEGORY_ENDPOINT}/{category_id}", + GetKBCategory, + failure_message=f"Failed to get KB category {category_id}", + ) + + async def create_kb_category(self, category: PostKBCategory) -> int: + """Create one knowledge base category and return its new identifier.""" + + return await self._resource_create( + KB_CATEGORY_ENDPOINT, + category, + failure_message="Failed to create KB category", + missing_message="GLPI KB category create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created KB category {new_id}", + ) + + async def update_kb_category( + self, category_id: GlpiId, category: PatchKBCategory + ) -> None: + """Update one knowledge base category with a partial body.""" + + await self._resource_update( + f"{KB_CATEGORY_ENDPOINT}/{category_id}", + category, + failure_message=f"Failed to update KB category {category_id}", + log_message=f"GLPI API updated KB category {category_id}", + ) + + async def delete_kb_category( + self, category_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one knowledge base category by identifier.""" + + await self._resource_delete( + f"{KB_CATEGORY_ENDPOINT}/{category_id}", + failure_message=f"Failed to delete KB category {category_id}", + log_message=f"GLPI API deleted KB category {category_id}", + force=force, + delete_model_cls=DeleteKBCategory, + ) + + +__all__ = ["KBCategoryMixin"] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_comment.py b/glpi_python_client/_async/clients/api/knowledgebase/_comment.py new file mode 100644 index 0000000..ec2d770 --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/_comment.py @@ -0,0 +1,109 @@ +"""GLPI ``/Knowledgebase/Article/{id}/Comment`` mixin. + +The mixin exposes list, fetch, create, update, and delete helpers for the +GLPI knowledge base article comment endpoint using the contract-aligned +``api_schema`` models. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + KB_ARTICLE_ENDPOINT, + KB_COMMENT_SUFFIX, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.knowledgebase._comment import ( + DeleteKBArticleComment, + GetKBArticleComment, + PatchKBArticleComment, + PostKBArticleComment, +) + + +class KBArticleCommentMixin(TransportMixin): + """CRUD helpers for KB article comments.""" + + async def list_kb_article_comments( + self, article_id: GlpiId + ) -> list[GetKBArticleComment]: + """List every comment attached to one knowledge base article.""" + + return await self._resource_list( + f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}", + GetKBArticleComment, + failure_message=f"Failed to list comments for KB article {article_id}", + ) + + async def get_kb_article_comment( + self, article_id: GlpiId, comment_id: GlpiId + ) -> GetKBArticleComment: + """Fetch one knowledge base article comment by identifier.""" + + return await self._resource_get( + f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}/{comment_id}", + GetKBArticleComment, + failure_message=( + f"Failed to get comment {comment_id} on KB article {article_id}" + ), + ) + + async def create_kb_article_comment( + self, article_id: GlpiId, comment: PostKBArticleComment + ) -> int: + """Create one comment on a knowledge base article.""" + + return await self._resource_create( + f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}", + comment, + failure_message=f"Failed to create comment on KB article {article_id}", + missing_message="GLPI KB comment create response did not include an ID", + log_message_factory=( + lambda new_id: ( + f"GLPI API created comment {new_id} on KB article {article_id}" + ) + ), + ) + + async def update_kb_article_comment( + self, + article_id: GlpiId, + comment_id: GlpiId, + comment: PatchKBArticleComment, + ) -> None: + """Update one knowledge base article comment with a partial body.""" + + await self._resource_update( + f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}/{comment_id}", + comment, + failure_message=( + f"Failed to update comment {comment_id} on KB article {article_id}" + ), + log_message=( + f"GLPI API updated comment {comment_id} on KB article {article_id}" + ), + ) + + async def delete_kb_article_comment( + self, + article_id: GlpiId, + comment_id: GlpiId, + *, + force: bool | None = None, + ) -> None: + """Delete one knowledge base article comment by identifier.""" + + await self._resource_delete( + f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_COMMENT_SUFFIX}/{comment_id}", + failure_message=( + f"Failed to delete comment {comment_id} on KB article {article_id}" + ), + log_message=( + f"GLPI API deleted comment {comment_id} on KB article {article_id}" + ), + force=force, + delete_model_cls=DeleteKBArticleComment, + ) + + +__all__ = ["KBArticleCommentMixin"] diff --git a/glpi_python_client/_async/clients/api/knowledgebase/_revision.py b/glpi_python_client/_async/clients/api/knowledgebase/_revision.py new file mode 100644 index 0000000..2df2b8b --- /dev/null +++ b/glpi_python_client/_async/clients/api/knowledgebase/_revision.py @@ -0,0 +1,70 @@ +"""GLPI ``/Knowledgebase/Article/{id}/Revision`` mixin. + +Revisions are read-only. The GLPI contract exposes both a default-language +listing (``.../Revision``) and a language-scoped listing +(``.../{language}/Revision``); this mixin folds both into two helpers that +take an optional ``language`` argument and build the matching path. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.commons._constants import ( + KB_ARTICLE_ENDPOINT, + KB_REVISION_SUFFIX, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.knowledgebase._revision import ( + GetKBArticleRevision, +) + + +class KBArticleRevisionMixin(TransportMixin): + """Read helpers for KB article revisions.""" + + def _revision_base(self, article_id: GlpiId, language: str | None) -> str: + """Return the revision collection path, language-scoped when given.""" + + if language: + return f"{KB_ARTICLE_ENDPOINT}/{article_id}/{language}/{KB_REVISION_SUFFIX}" + return f"{KB_ARTICLE_ENDPOINT}/{article_id}/{KB_REVISION_SUFFIX}" + + async def list_kb_article_revisions( + self, article_id: GlpiId, *, language: str | None = None + ) -> list[GetKBArticleRevision]: + """List revisions of one knowledge base article. + + Parameters + ---------- + article_id : GlpiId + Numeric identifier of the parent article. + language : str | None, optional + When provided, list revisions for that GLPI language code using + the language-scoped contract path. + """ + + return await self._resource_list( + self._revision_base(article_id, language), + GetKBArticleRevision, + failure_message=f"Failed to list revisions for KB article {article_id}", + ) + + async def get_kb_article_revision( + self, + article_id: GlpiId, + revision: int, + *, + language: str | None = None, + ) -> GetKBArticleRevision: + """Fetch one revision of a knowledge base article by revision number.""" + + return await self._resource_get( + f"{self._revision_base(article_id, language)}/{revision}", + GetKBArticleRevision, + failure_message=( + f"Failed to get revision {revision} of KB article {article_id}" + ), + ) + + +__all__ = ["KBArticleRevisionMixin"] diff --git a/glpi_python_client/_async/clients/api/management/__init__.py b/glpi_python_client/_async/clients/api/management/__init__.py new file mode 100644 index 0000000..4116a74 --- /dev/null +++ b/glpi_python_client/_async/clients/api/management/__init__.py @@ -0,0 +1,7 @@ +"""GLPI ``/Management`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.management._document import DocumentMixin + +__all__ = ["DocumentMixin"] diff --git a/glpi_python_client/_async/clients/api/management/_document.py b/glpi_python_client/_async/clients/api/management/_document.py new file mode 100644 index 0000000..3f57e3e --- /dev/null +++ b/glpi_python_client/_async/clients/api/management/_document.py @@ -0,0 +1,281 @@ +"""GLPI ``/Management/Document`` mixin. + +The mixin exposes JSON metadata CRUD operations on the document resource and +a multipart upload helper that delegates to the legacy v1 session because +the v2 API does not advertise a binary upload endpoint in the contract. +""" + +from __future__ import annotations + +import logging + +from glpi_python_client._async.clients.commons._constants import ( + DOCUMENT_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._http import ensure_response_status +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError +from glpi_python_client.models.api_schema.management._document import ( + DeleteDocument, + GetDocument, + PatchDocument, + PostDocument, +) + +logger = logging.getLogger(__name__) + + +class DocumentMixin(TransportMixin): + """CRUD and upload helpers for ``/Management/Document``.""" + + async def search_documents( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + ) -> list[GetDocument]: + """Search GLPI documents with an optional raw RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL expression forwarded to the ``filter`` query + parameter (for example ``"name=='*manual*'"``). When empty + the parameter is omitted and the server returns its default + paginated listing. + limit : int, optional + Maximum number of records to return (defaults to 50). + start : int, optional + Zero-based offset for pagination (defaults to 0). + + Returns + ------- + list[GetDocument] + Documents matching the filter window. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return await self._resource_list( + DOCUMENT_ENDPOINT, GetDocument, params=params, skip_entity=True + ) + + async def get_document(self, document_id: GlpiId) -> GetDocument: + """Fetch one GLPI document by identifier. + + Parameters + ---------- + document_id : GlpiId + Numeric identifier of the document to retrieve. + + Returns + ------- + GetDocument + Validated document metadata payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{DOCUMENT_ENDPOINT}/{document_id}", + GetDocument, + failure_message=f"Failed to get document {document_id}", + skip_entity=True, + ) + + async def create_document(self, document: PostDocument) -> int: + """Create one GLPI document metadata record. + + Binary uploads use :meth:`upload_document` instead of the JSON + metadata endpoint exposed here. + + Parameters + ---------- + document : PostDocument + Request body describing the document metadata. + + Returns + ------- + int + Identifier assigned by the GLPI server to the new document. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + DOCUMENT_ENDPOINT, + document, + failure_message="Failed to create document", + missing_message="GLPI document create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created document {new_id}", + skip_entity=True, + ) + + async def update_document( + self, document_id: GlpiId, document: PatchDocument + ) -> None: + """Update one GLPI document with a partial body. + + Parameters + ---------- + document_id : GlpiId + Numeric identifier of the document to update. + document : PatchDocument + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{DOCUMENT_ENDPOINT}/{document_id}", + document, + failure_message=f"Failed to update document {document_id}", + log_message=f"GLPI API updated document {document_id}", + ) + + async def delete_document( + self, document_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI document by identifier. + + Parameters + ---------- + document_id : GlpiId + Numeric identifier of the document to delete. + force : bool | None, optional + When ``True`` the document is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{DOCUMENT_ENDPOINT}/{document_id}", + failure_message=f"Failed to delete document {document_id}", + log_message=f"GLPI API deleted document {document_id}", + force=force, + delete_model_cls=DeleteDocument, + skip_entity=True, + ) + + async def download_document_content(self, document_id: GlpiId) -> bytes: + """Download the raw binary payload for one GLPI document. + + Parameters + ---------- + document_id : GlpiId + Numeric identifier of the document whose binary content is + requested. + + Returns + ------- + bytes + Raw bytes returned by the GLPI download endpoint. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + response = await self._get_request( + f"{DOCUMENT_ENDPOINT}/{document_id}/Download", + skip_entity=True, + ) + ensure_response_status( + response, + success_statuses=(200,), + failure_message=f"Failed to download document {document_id}", + ) + return response.content + + async def upload_document( + self, + *, + filename: str, + content: bytes, + mime_type: str = "application/octet-stream", + document_name: str | None = None, + ticket_id: int | None = None, + entity_id: int | None = None, + ) -> dict[str, object]: + """Upload one binary document via the legacy v1 multipart endpoint. + + Document uploads use the legacy v1 multipart endpoint because + the GLPI v2 API does not advertise a binary upload route. The + async :class:`~glpi_python_client._async.clients.AsyncGlpiClient` + offloads this blocking call to a worker thread automatically; + callers using the sync :class:`~glpi_python_client._async.clients.GlpiClient` + invoke it directly. + + Parameters + ---------- + filename : str + Name to advertise in the multipart form. Required and must + be non-empty. + content : bytes + Raw binary payload to upload. + mime_type : str, optional + MIME type advertised in the multipart part (defaults to + ``application/octet-stream``). + document_name : str | None, optional + Human-readable display name. Defaults to ``filename`` when + omitted. + ticket_id : int | None, optional + Identifier of one ticket to attach the uploaded document to. + entity_id : int | None, optional + Identifier of one GLPI entity to scope the upload to. + + Returns + ------- + dict[str, object] + Raw JSON dictionary returned by the legacy v1 upload + endpoint. + + Raises + ------ + GlpiValidationError + If ``filename`` is empty. + RuntimeError + If the v1 session is not configured on the client. + """ + + if not filename: + raise GlpiValidationError("GLPI document upload requires a filename") + v1 = self._require_v1_session("document uploads") + return await v1.upload_document( + filename, + content, + mime_type, + document_name=document_name, + ticket_id=ticket_id, + entity_id=entity_id, + ) + + +__all__ = ["DocumentMixin"] diff --git a/glpi_python_client/_async/clients/api/plugins/__init__.py b/glpi_python_client/_async/clients/api/plugins/__init__.py new file mode 100644 index 0000000..2c1a920 --- /dev/null +++ b/glpi_python_client/_async/clients/api/plugins/__init__.py @@ -0,0 +1,10 @@ +"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API. + +Plugins are not advertised in the v2 OpenAPI contract so the mixins +under this package go through the v1 session helper exposed by +:class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`. +""" + +from glpi_python_client._async.clients.api.plugins._fields import PluginFieldsMixin + +__all__ = ["PluginFieldsMixin"] diff --git a/glpi_python_client/_async/clients/api/plugins/_fields.py b/glpi_python_client/_async/clients/api/plugins/_fields.py new file mode 100644 index 0000000..a7ba61b --- /dev/null +++ b/glpi_python_client/_async/clients/api/plugins/_fields.py @@ -0,0 +1,426 @@ +"""Client mixin for the GLPI ``Fields`` plugin. + +The `Fields plugin `_ adds +user-defined custom fields to any GLPI itemtype. It is not exposed +through the GLPI v2 REST contract so this mixin talks to the legacy v1 +REST API through :class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`. + +Two abstraction layers are provided: + +* low-level helpers — :meth:`list_plugin_fields_containers`, + :meth:`list_plugin_fields_fields`, + :meth:`list_item_plugin_field_rows`, + :meth:`update_item_plugin_field_row`, + :meth:`create_item_plugin_field_row` — mirror one v1 endpoint each + and stay generic across itemtypes. +* ticket-focused convenience helpers — + :meth:`get_ticket_custom_fields` and :meth:`set_ticket_custom_fields` + — fold the discovery + CRUD calls into a single round-trip that + returns / accepts a ``{container_name: {field_name: value}}`` + mapping. + +The value itemtype for one container is derived from the container +``name`` field with :func:`_value_itemtype_for`: container +``aidelarsolution`` attached to ``Ticket`` becomes +``PluginFieldsTicketaidelarsolution``. Field column names declared on +:class:`~glpi_python_client.models.api_schema.plugins.GetPluginFieldsField` +flow through :attr:`~glpi_python_client.models._base.GlpiModel.extra_payload` +on the value rows. +""" + +from __future__ import annotations + +import json +from typing import Any + +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError +from glpi_python_client.models.api_schema.plugins import ( + GetPluginFieldsContainer, + GetPluginFieldsField, + GetPluginFieldsValueRow, +) + +_TICKET_ITEMTYPE = "Ticket" +_DEFAULT_LIST_RANGE = "0-999" +_V1_FEATURE_LABEL = "Fields plugin helpers" + + +def _value_itemtype_for(itemtype: str, container_name: str) -> str: + """Return the value-row itemtype for a (parent itemtype, container) pair. + + The plugin uses the ``PluginFields`` convention. The + parent itemtype keeps its original casing and the container name is + lower-cased to match the v1 server-side route registration. + """ + + return f"PluginFields{itemtype}{container_name.lower()}" + + +def _container_targets_itemtype( + container: GetPluginFieldsContainer, itemtype: str +) -> bool: + """Return whether ``container`` is attached to ``itemtype``. + + The v1 API returns ``itemtypes`` as a JSON-encoded string; failure + to parse falls back to a permissive substring check. + """ + + raw = container.itemtypes + if not raw: + return False + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return itemtype in raw + if isinstance(parsed, list): + return itemtype in parsed + return False + + +def _extract_row_id(payload: object) -> int: + """Return the row id reported by the v1 API for a CRUD response. + + The v1 plugin endpoints return ``[{"": true, "message": ""}]`` + where ```` is the affected row identifier. ``GlpiProtocolError`` + is raised when the payload does not match this shape. + + Raises + ------ + GlpiProtocolError + When ``payload`` is not a non-empty list of mappings, or no + mapping key parses as a numeric row id. + """ + + if not isinstance(payload, list) or not payload: + raise GlpiProtocolError( + f"GLPI Fields plugin response missing row id: {payload!r}" + ) + first = payload[0] + if not isinstance(first, dict): + raise GlpiProtocolError( + f"GLPI Fields plugin response not a mapping: {payload!r}" + ) + for key in first: + if key == "message": + continue + try: + return int(key) + except (TypeError, ValueError): + continue + raise GlpiProtocolError( + f"GLPI Fields plugin response did not include a numeric id: {payload!r}" + ) + + +class PluginFieldsMixin(TransportMixin): + """Helpers for the GLPI ``Fields`` plugin v1 endpoints. + + Every method requires the v1 session to be configured on the client + (see :class:`~glpi_python_client._async.clients.sync_client.GlpiClient`'s + ``v1_base_url`` and ``v1_user_token`` constructor arguments). + """ + + async def list_plugin_fields_containers( + self, itemtype: str | None = None + ) -> list[GetPluginFieldsContainer]: + """List ``PluginFieldsContainer`` rows registered on the server. + + Parameters + ---------- + itemtype : str | None, optional + When provided, only the containers attached to ``itemtype`` + are returned. The filtering happens client-side because the + v1 API stores ``itemtypes`` as a JSON-encoded string. + + Returns + ------- + list[GetPluginFieldsContainer] + Containers visible to the authenticated user. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + payload = await v1.request_json( + "GET", + "PluginFieldsContainer", + params={"range": _DEFAULT_LIST_RANGE}, + failure_message="Failed to list PluginFieldsContainer", + ) + rows = payload if isinstance(payload, list) else [] + containers = [GetPluginFieldsContainer.model_validate(row) for row in rows] + if itemtype is None: + return containers + return [c for c in containers if _container_targets_itemtype(c, itemtype)] + + async def list_plugin_fields_fields( + self, container_id: int | None = None + ) -> list[GetPluginFieldsField]: + """List ``PluginFieldsField`` declarations. + + Parameters + ---------- + container_id : int | None, optional + When provided, restricts the result to the fields declared + on this container (the filtering is performed client-side + because the v1 API does not consistently honour the + ``searchText`` filter on this itemtype). + + Returns + ------- + list[GetPluginFieldsField] + Field declarations visible to the authenticated user. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + payload = await v1.request_json( + "GET", + "PluginFieldsField", + params={"range": _DEFAULT_LIST_RANGE}, + failure_message="Failed to list PluginFieldsField", + ) + rows = payload if isinstance(payload, list) else [] + fields = [GetPluginFieldsField.model_validate(row) for row in rows] + if container_id is None: + return fields + return [f for f in fields if f.plugin_fields_containers_id == container_id] + + async def list_item_plugin_field_rows( + self, + itemtype: str, + items_id: int, + container_name: str, + ) -> list[GetPluginFieldsValueRow]: + """List the value rows of one container for one parent item. + + Parameters + ---------- + itemtype : str + Parent itemtype (e.g. ``"Ticket"``). + items_id : int + Identifier of the parent item. + container_name : str + Internal name of the container as exposed by + :attr:`GetPluginFieldsContainer.name`. + + Returns + ------- + list[GetPluginFieldsValueRow] + Zero or one row depending on whether the plugin has already + persisted any value for this item. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + endpoint = ( + f"{itemtype}/{items_id}/{_value_itemtype_for(itemtype, container_name)}" + ) + payload = await v1.request_json( + "GET", + endpoint, + failure_message=f"Failed to list {endpoint}", + ) + rows = payload if isinstance(payload, list) else [] + return [GetPluginFieldsValueRow.model_validate(row) for row in rows] + + async def create_item_plugin_field_row( + self, + *, + itemtype: str, + items_id: int, + container_id: int, + container_name: str, + values: dict[str, object], + entities_id: int | None = None, + ) -> int: + """Create one fresh plugin-fields value row. + + Parameters + ---------- + itemtype : str + Parent itemtype (e.g. ``"Ticket"``). + items_id : int + Identifier of the parent item the row is attached to. + container_id : int + Identifier of the originating + :class:`GetPluginFieldsContainer`. + container_name : str + Internal name of the container, used to derive the value + itemtype. + values : dict[str, object] + Field-name → value mapping for the dynamic columns declared + on the container. + entities_id : int | None, optional + Entity to associate the row with. When omitted the GLPI + server applies its default scope. + + Returns + ------- + int + Identifier of the newly created row. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + input_payload: dict[str, object] = { + "items_id": items_id, + "itemtype": itemtype, + "plugin_fields_containers_id": container_id, + **values, + } + if entities_id is not None: + input_payload["entities_id"] = entities_id + response = await v1.request_json( + "POST", + _value_itemtype_for(itemtype, container_name), + json_body={"input": input_payload}, + failure_message=( + f"Failed to create {_value_itemtype_for(itemtype, container_name)} " + f"row for {itemtype} {items_id}" + ), + ) + return _extract_row_id(response) + + async def update_item_plugin_field_row( + self, + *, + itemtype: str, + container_name: str, + row_id: int, + values: dict[str, object], + ) -> None: + """Update one existing plugin-fields value row. + + Parameters + ---------- + itemtype : str + Parent itemtype the container is attached to. + container_name : str + Internal name of the container. + row_id : int + Identifier of the existing value row (as returned by + :meth:`list_item_plugin_field_rows`). + values : dict[str, object] + Field-name → value mapping for the columns to update. Only + the fields supplied here are touched; the others keep their + previous value. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + endpoint = f"{_value_itemtype_for(itemtype, container_name)}/{row_id}" + await v1.request_json( + "PUT", + endpoint, + json_body={"input": {"id": row_id, **values}}, + failure_message=f"Failed to update {endpoint}", + ) + + async def get_ticket_custom_fields( + self, ticket_id: int + ) -> dict[str, dict[str, Any]]: + """Return the custom-field values defined for one ticket. + + The result is a nested mapping shaped as + ``{container_name: {field_name: value, ...}}``. Containers that + do not yet have a persisted value row for the ticket are + skipped. + + Parameters + ---------- + ticket_id : int + Identifier of the ticket whose custom values are requested. + + Returns + ------- + dict[str, dict[str, Any]] + Per-container value mappings. Empty when the ticket has no + stored custom values across any container. + """ + + containers = await self.list_plugin_fields_containers(itemtype=_TICKET_ITEMTYPE) + result: dict[str, dict[str, Any]] = {} + for container in containers: + name = container.name + if not name: + continue + rows = await self.list_item_plugin_field_rows( + _TICKET_ITEMTYPE, ticket_id, name + ) + if not rows: + continue + result[name] = dict(rows[0].extra_payload) + return result + + async def set_ticket_custom_fields( + self, + ticket_id: int, + values: dict[str, dict[str, Any]], + ) -> None: + """Persist custom-field values on one ticket. + + Existing value rows are updated in place; missing rows are + created with the supplied payload. Containers/fields that the + server does not know about raise ``GlpiValidationError`` *before* + any write to keep the call atomic from the caller's perspective. + + Parameters + ---------- + ticket_id : int + Identifier of the ticket whose custom values must be set. + values : dict[str, dict[str, Any]] + Nested mapping ``{container_name: {field_name: value}}`` + describing the columns to write. Container and field names + must match what :meth:`list_plugin_fields_containers` and + :meth:`list_plugin_fields_fields` return. + """ + + if not values: + return + + containers = await self.list_plugin_fields_containers(itemtype=_TICKET_ITEMTYPE) + by_name: dict[str, GetPluginFieldsContainer] = { + c.name: c for c in containers if c.name is not None + } + unknown = sorted(set(values) - set(by_name)) + if unknown: + raise GlpiValidationError( + "Unknown plugin-fields container(s) for Ticket: " + ", ".join(unknown) + ) + + for container_name, column_values in values.items(): + container = by_name[container_name] + if container.id is None: + raise GlpiProtocolError( + f"Container {container_name!r} has no id; cannot write values" + ) + + declared = { + f.name + for f in await self.list_plugin_fields_fields(container_id=container.id) + if f.name is not None + } + unknown_fields = sorted(set(column_values) - declared) + if unknown_fields: + raise GlpiValidationError( + f"Unknown field(s) for container {container_name!r}: " + + ", ".join(unknown_fields) + ) + + existing_rows = await self.list_item_plugin_field_rows( + _TICKET_ITEMTYPE, ticket_id, container_name + ) + if existing_rows and existing_rows[0].id is not None: + await self.update_item_plugin_field_row( + itemtype=_TICKET_ITEMTYPE, + container_name=container_name, + row_id=existing_rows[0].id, + values=column_values, + ) + else: + await self.create_item_plugin_field_row( + itemtype=_TICKET_ITEMTYPE, + items_id=ticket_id, + container_id=container.id, + container_name=container_name, + values=column_values, + ) + + +__all__ = ["PluginFieldsMixin"] diff --git a/glpi_python_client/_async/clients/client.py b/glpi_python_client/_async/clients/client.py new file mode 100644 index 0000000..2fc4316 --- /dev/null +++ b/glpi_python_client/_async/clients/client.py @@ -0,0 +1,135 @@ +"""Public GLPI client class. + +Composes the per-endpoint mixins from +:mod:`glpi_python_client._async.clients.api` with the aggregated helpers +from :mod:`glpi_python_client._async.clients.custom` and the transport +mixin from :mod:`glpi_python_client._async.clients.commons` to expose the +full public client surface. + +This module is written once. Its counterpart on the other surface is +generated from it, so the two client classes cannot drift apart: there is +no second definition to keep in step. +""" + +from __future__ import annotations + +import logging +import sys +from types import TracebackType + +if sys.version_info >= (3, 11): + from typing import Self +else: # pragma: no cover - fallback for Python 3.10 + from typing_extensions import Self + +from glpi_python_client._async.clients._base_client import _BaseGlpiClient +from glpi_python_client._async.clients.api import ( + DocumentMixin, + EntityMixin, + FollowupMixin, + KBArticleCommentMixin, + KBArticleMixin, + KBArticleRevisionMixin, + KBCategoryMixin, + LocationMixin, + PluginFieldsMixin, + SolutionMixin, + TeamMemberMixin, + TicketMixin, + TicketTaskMixin, + TimelineDocumentMixin, + UserMixin, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._async.clients.custom import ( + StatisticsMixin, + TicketContextMixin, +) + +logger = logging.getLogger(__name__) + + +class AsyncGlpiClient( + TicketMixin, + TicketTaskMixin, + FollowupMixin, + SolutionMixin, + TimelineDocumentMixin, + TeamMemberMixin, + DocumentMixin, + UserMixin, + EntityMixin, + LocationMixin, + KBCategoryMixin, + KBArticleMixin, + KBArticleCommentMixin, + KBArticleRevisionMixin, + PluginFieldsMixin, + TicketContextMixin, + StatisticsMixin, + _BaseGlpiClient, + TransportMixin, +): + """GLPI client backed by the contract-aligned API mixins. + + The client owns the shared HTTP session, the OAuth token manager, and + the optional legacy v1 session used for binary document uploads and + the Fields plugin endpoints. Token acquisition is serialised by the + lock from :mod:`glpi_python_client._async._concurrency`, which is the + right primitive for this surface -- see that module for why the two + surfaces cannot share one. + + Construction parameters and :meth:`from_env` are documented on + :class:`~glpi_python_client._async.clients._base_client._BaseGlpiClient`. + """ + + async def close(self) -> None: + """Release every resource owned by the client. + + The shared HTTP session is closed, the optional v1 fallback + session is closed, and the client is marked as closed so + subsequent calls raise immediately. The method is idempotent. + """ + + if self._closed: + return + try: + await self._session.aclose() + if self._v1 is not None: + await self._v1.close() + finally: + self._closed = True + + async def __aenter__(self) -> Self: + """Return the client unchanged for use in a ``with`` block. + + Returns + ------- + AsyncGlpiClient + The client itself, suitable for chaining method calls. + """ + + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + """Close the client on ``with`` block exit. + + Parameters + ---------- + exc_type : type[BaseException] | None + Exception class raised inside the ``with`` block, if any. + exc : BaseException | None + Exception instance raised inside the block, if any. + tb : TracebackType | None + Traceback associated with ``exc``. + """ + + await self.close() + + +__all__ = ["AsyncGlpiClient"] diff --git a/glpi_python_client/clients/commons/__init__.py b/glpi_python_client/_async/clients/commons/__init__.py similarity index 58% rename from glpi_python_client/clients/commons/__init__.py rename to glpi_python_client/_async/clients/commons/__init__.py index b2130c6..149375a 100644 --- a/glpi_python_client/clients/commons/__init__.py +++ b/glpi_python_client/_async/clients/commons/__init__.py @@ -2,8 +2,8 @@ The commons package centralises constants, HTTP helpers, RSQL filter builders, transport, and the client configuration helpers -used by the per-endpoint mixins under :mod:`glpi_python_client.clients.api` -and the higher-level helpers under :mod:`glpi_python_client.clients.custom`. +used by the per-endpoint mixins under :mod:`glpi_python_client._async.clients.api` +and the higher-level helpers under :mod:`glpi_python_client._async.clients.custom`. """ from __future__ import annotations diff --git a/glpi_python_client/_async/clients/commons/_config.py b/glpi_python_client/_async/clients/commons/_config.py new file mode 100644 index 0000000..0de514a --- /dev/null +++ b/glpi_python_client/_async/clients/commons/_config.py @@ -0,0 +1,316 @@ +"""Configuration and resource setup for the GLPI client. + +The helpers here own environment parsing, URL normalisation, and the +construction of the runtime resources the client owns: the shared HTTP +session, the OAuth token manager, and the optional legacy v1 session. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +import httpx + +from glpi_python_client._errors import GlpiValidationError + +if TYPE_CHECKING: + from glpi_python_client._async.auth._v1_session import GLPIV1Session + from glpi_python_client._async.auth.auth import GLPITokenManager + +#: Request timeout applied to every call that does not override it. +#: +#: ``httpx`` defaults to 5 seconds where ``requests`` defaults to no timeout +#: at all, so leaving this unset would silently start cutting off the slow +#: GLPI searches that used to be allowed to finish. 30s matches the value the +#: transport has always passed explicitly. +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +class SessionFactory(Protocol): + """Callable that builds the transport session for a client. + + Declared as a ``Protocol`` rather than a bare ``Callable`` alias so the + keyword-only ``verify_ssl`` argument is part of the contract: the whole + point of this seam is that the SSL policy is supplied *at construction*, + and a positional-argument factory would let that guarantee slip. + """ + + def __call__(self, *, verify_ssl: bool) -> httpx.AsyncClient: + """Return a session configured for ``verify_ssl``.""" + + +@dataclass(frozen=True) +class ClientResources: + """Runtime resources owned by one async ``GlpiClient`` instance. + + The bundle keeps shared HTTP session, token manager, and optional v1 + upload session tied together so the client can release them as a unit. + """ + + glpi_api_url: str + session: httpx.AsyncClient + auth: GLPITokenManager + v1: GLPIV1Session | None + + +def build_http_session(*, verify_ssl: bool) -> httpx.AsyncClient: + """Construct the HTTP client used for every GLPI call. + + This is the single place the library instantiates a transport session, + which is what makes the transport swappable at all. Three settings are + applied here deliberately, because each one differs between ``httpx`` + and the ``requests`` transport this replaced: + + * ``verify`` is applied **as part of construction**. ``httpx`` reads it + only in ``Client.__init__``; a later assignment is accepted and + silently ignored, which would leave certificate verification on when + the caller asked for it off. + * ``follow_redirects`` is enabled to preserve the previous behaviour. + ``requests`` follows redirects by default and ``httpx`` does not, so + omitting this would silently turn a followed redirect into a bare 3xx + response handed back to the caller. + * ``timeout`` is pinned to :data:`DEFAULT_TIMEOUT_SECONDS` rather than + left at the ``httpx`` default of 5 seconds. + + Callers that need to intercept traffic (tests, and anything wanting + ``httpx.MockTransport``) can substitute this factory instead of + monkey-patching a session after the fact. + + Parameters + ---------- + verify_ssl : bool + Whether TLS certificates are verified. + + Returns + ------- + httpx.AsyncClient + A client configured for the requested SSL policy. + """ + + return httpx.AsyncClient( + verify=verify_ssl, + follow_redirects=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + +def build_client_resources( + *, + glpi_api_url: object, + client_name: str, + client_id: str | None, + client_secret: str | None, + username: str | None, + password: str | None, + verify_ssl: bool, + auth_token_refresh: int | None, + v1_base_url: str | None, + v1_user_token: str | None, + v1_app_token: str | None, + session_factory: SessionFactory | None = None, +) -> ClientResources: + """Build the shared resources required by one async client instance. + + The helper validates the API URL, configures SSL behaviour, builds the + OAuth token manager, and optionally instantiates the legacy v1 session + used solely by the document upload mixin. + + Parameters + ---------- + session_factory : SessionFactory | None, optional + Override for :func:`build_http_session`, called with the resolved + ``verify_ssl`` policy. This is the transport-injection seam: it lets + a caller supply a session wired to a stub transport without patching + module globals. ``None`` uses the default factory. + """ + + from glpi_python_client._async.auth._v1_session import GLPIV1Session + from glpi_python_client._async.auth.auth import ( + GLPITokenManager, + validate_credentials, + ) + + normalized_api_url = normalize_client_api_url( + glpi_api_url, + client_name=client_name, + ) + validate_v1_document_config( + v1_base_url=v1_base_url, + v1_user_token=v1_user_token, + ) + + # Validated before anything is constructed, so a bad configuration never + # leaves a session to unwind. The previous shape -- build the session, + # then close it in an ``except`` clause -- cannot work on the async + # surface: an ``httpx.AsyncClient`` has no synchronous close, and this + # runs from ``__init__``, which cannot await one. + validate_credentials( + client_id=client_id, + client_secret=client_secret, + username=username, + password=password, + ) + + factory = session_factory or build_http_session + session = factory(verify_ssl=verify_ssl) + auth = GLPITokenManager( + token_url=f"{normalized_api_url}/token", + client_id=client_id, + client_secret=client_secret, + username=username, + password=password, + session=session, + auth_token_refresh=auth_token_refresh, + ) + + v1: GLPIV1Session | None = None + if v1_base_url and v1_user_token: + v1 = GLPIV1Session( + base_url=v1_base_url, + user_token=v1_user_token, + app_token=v1_app_token, + verify_ssl=verify_ssl, + ) + + return ClientResources( + glpi_api_url=normalized_api_url, + session=session, + auth=auth, + v1=v1, + ) + + +def parse_optional_env_int(value: object) -> int | None: + """Parse one optional integer from an environment-style value. + + ``None`` is preserved, native integers are accepted as-is, and strings + are converted through ``int()`` so explicit overrides and environment + values follow the same normalisation path. + + Raises + ------ + GlpiValidationError + If a string value cannot be parsed as an integer (e.g. + ``GLPI_TIMEOUT=abc``). + TypeError + If ``value`` is neither ``None``, ``int``, nor ``str``. + """ + + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError as exc: + raise GlpiValidationError( + f"Invalid integer environment value: {value!r}" + ) from exc + raise TypeError("Integer environment values must be strings or integers") + + +def parse_optional_env_bool(value: object, *, default: bool) -> bool: + """Parse one optional boolean from an environment-style value. + + String values follow the conventional true and false spellings accepted + by the package configuration helpers, while ``None`` falls back to the + caller-provided default. + """ + + if value is None: + return default + if isinstance(value, bool): + return value + if not isinstance(value, str): + raise TypeError("Boolean environment values must be strings or booleans") + if value.casefold() in {"1", "true", "yes", "on"}: + return True + if value.casefold() in {"0", "false", "no", "off"}: + return False + raise GlpiValidationError(f"Invalid boolean environment value: {value!r}") + + +def build_client_env_config( + *, + prefix: str, + env: Mapping[str, str], + overrides: Mapping[str, object], +) -> dict[str, object]: + """Build common GLPI client config values from environment variables. + + The returned mapping matches the constructor keyword arguments accepted + by :class:`GlpiClient`, making it suitable for direct unpacking. + """ + + config: dict[str, object] = { + "glpi_api_url": env.get(f"{prefix}API_URL"), + "client_id": env.get(f"{prefix}CLIENT_ID"), + "client_secret": env.get(f"{prefix}CLIENT_SECRET"), + "username": env.get(f"{prefix}USERNAME"), + "password": env.get(f"{prefix}PASSWORD"), + "glpi_entity": parse_optional_env_int(env.get(f"{prefix}ENTITY")), + "glpi_profile": parse_optional_env_int(env.get(f"{prefix}PROFILE")), + "entity_recursive": parse_optional_env_bool( + env.get(f"{prefix}ENTITY_RECURSIVE"), + default=False, + ), + "language": env.get(f"{prefix}LANGUAGE") or "en_GB", + "verify_ssl": parse_optional_env_bool( + env.get(f"{prefix}VERIFY_SSL"), + default=True, + ), + "auth_token_refresh": parse_optional_env_int( + env.get(f"{prefix}AUTH_TOKEN_REFRESH") + ), + "v1_base_url": env.get(f"{prefix}V1_BASE_URL"), + "v1_user_token": env.get(f"{prefix}V1_USER_TOKEN"), + "v1_app_token": env.get(f"{prefix}V1_APP_TOKEN"), + } + config.update(overrides) + return config + + +def normalize_client_api_url(glpi_api_url: object, *, client_name: str) -> str: + """Validate and normalise the configured GLPI API base URL. + + The helper rejects missing or non-string values early and strips a + trailing slash so endpoint assembly remains consistent across the + client codebase. + """ + + if not isinstance(glpi_api_url, str) or not glpi_api_url: + raise GlpiValidationError(f"{client_name} requires glpi_api_url") + return glpi_api_url.rstrip("/") + + +def validate_v1_document_config( + *, + v1_base_url: str | None, + v1_user_token: str | None, +) -> None: + """Validate the paired legacy v1 document configuration values. + + Document uploads require both the legacy base URL and the user token. + The helper rejects partial configuration before a client is constructed. + """ + + if bool(v1_base_url) != bool(v1_user_token): + raise GlpiValidationError( + "GLPI v1 document support requires both v1_base_url and v1_user_token." + ) + + +__all__ = [ + "DEFAULT_TIMEOUT_SECONDS", + "ClientResources", + "build_client_env_config", + "build_client_resources", + "build_http_session", + "normalize_client_api_url", + "parse_optional_env_bool", + "parse_optional_env_int", + "validate_v1_document_config", +] diff --git a/glpi_python_client/clients/commons/_constants.py b/glpi_python_client/_async/clients/commons/_constants.py similarity index 100% rename from glpi_python_client/clients/commons/_constants.py rename to glpi_python_client/_async/clients/commons/_constants.py diff --git a/glpi_python_client/clients/commons/_filters.py b/glpi_python_client/_async/clients/commons/_filters.py similarity index 100% rename from glpi_python_client/clients/commons/_filters.py rename to glpi_python_client/_async/clients/commons/_filters.py diff --git a/glpi_python_client/_async/clients/commons/_http.py b/glpi_python_client/_async/clients/commons/_http.py new file mode 100644 index 0000000..765cb40 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/_http.py @@ -0,0 +1,394 @@ +"""HTTP transport helpers shared across the GLPI v2 API mixins. + +The functions here normalise query parameters, build authenticated headers, +assemble request URLs, and validate responses so the per-endpoint mixins +can focus on resource-specific behaviour. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping + +import httpx + +from glpi_python_client._async.clients.commons._constants import RequestParamValue +from glpi_python_client._errors import ( + GlpiProtocolError, + GlpiServerError, + GlpiTimeoutError, + GlpiTransportError, + status_error_class, +) + + +def transport_error_from( + exc: httpx.HTTPError, + *, + method: str, + url: str, +) -> GlpiTransportError: + """Map one transport-level failure onto the library's public error type. + + Network faults are the last part of the failure surface that still + escaped as third-party exceptions. Translating them here means callers + catch :class:`~glpi_python_client.GlpiError` and never have to import the + HTTP library, which is what :class:`~glpi_python_client.GlpiTransportError` + was reserved for. + + It also removes a whole class of silent breakage. Retry predicates used + to name the HTTP library's own exception base; because those trees are + completely disjoint between libraries, swapping the transport without + editing every predicate made retries stop matching — silently, with no + error and a green test suite. Predicates now name this library-owned type + instead, so a future transport change cannot invalidate them. + + Parameters + ---------- + exc : httpx.HTTPError + The transport failure to translate. + method : str + HTTP verb, used only to build the message. + url : str + Absolute URL of the failed request, used only to build the message. + + Returns + ------- + GlpiTransportError + :class:`~glpi_python_client.GlpiTimeoutError` when the failure was a + timeout, otherwise :class:`~glpi_python_client.GlpiTransportError`. + The original exception should be attached with ``raise ... from exc`` + by the caller. + """ + + error_class = ( + GlpiTimeoutError + if isinstance(exc, httpx.TimeoutException) + else GlpiTransportError + ) + return error_class( + f"GLPI {method.upper()} {url} failed: {type(exc).__name__}: {exc}" + ) + + +def response_reason(response: httpx.Response) -> str: + """Return one response's HTTP reason phrase, whatever the transport. + + ``httpx`` spells this ``Response.reason_phrase``; ``requests`` spelled it + ``Response.reason``. Both spellings are probed so the helper keeps + working for the duck-typed response fakes in downstream test suites, + which were written against the older attribute name. + + Parameters + ---------- + response : httpx.Response + Response to read the reason phrase from. Typed against the current + transport; any object exposing either attribute works at runtime. + + Returns + ------- + str + The reason phrase, or ``""`` when the transport supplies none. + """ + + reason = getattr(response, "reason", None) + if reason is None: + reason = getattr(response, "reason_phrase", None) + return str(reason) if reason else "" + + +def request_params( + params: dict[str, object] | None, +) -> dict[str, RequestParamValue] | None: + """Normalise query parameters into transport-compatible values. + + Each value is converted through :func:`request_param_value` so callers + can pass richer Python objects without repeating serialisation logic. + + Keys whose value is ``None`` are **dropped** rather than forwarded. This + is deliberate and load-bearing: ``requests`` omitted such keys from the + query string entirely, whereas ``httpx`` encodes them as a valueless + ``key=``. Sending an empty value to GLPI is not a no-op — an empty filter + or search value is interpreted as "match everything", so forwarding the + key would silently widen a query instead of leaving it unconstrained. + Normalising here keeps the emitted query string identical across + transports. + """ + + if params is None: + return None + return { + key: request_param_value(value) + for key, value in params.items() + if value is not None + } + + +def request_param_value(value: object) -> RequestParamValue: + """Normalise one query parameter value for the HTTP transport. + + Values are rendered exactly as the previous ``requests``-based transport + rendered them, so the wire format does not depend on which HTTP library + is installed. Two conversions exist only to preserve that: + + * ``bytes`` are decoded to text. ``httpx`` would otherwise stringify the + object itself and emit the Python repr (``b'x'``) rather than its + contents. + * ``bool`` is rendered ``"True"``/``"False"``. ``httpx`` renders booleans + lowercase; ``requests`` did not. This is checked before ``int`` + because ``bool`` is a subclass of ``int``. + """ + + if value is None or isinstance(value, str): + return value + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if isinstance(value, bool): + return str(value) + if isinstance(value, int | float): + return value + return str(value) + + +def require_access_token(access_token: str | None) -> str: + """Return a usable access token or raise when it is missing. + + Transport helpers call this right before request dispatch so missing + token state turns into a clear local error instead of a malformed API + call. + + Raises + ------ + GlpiProtocolError + When ``access_token`` is empty or ``None``. + """ + + if not access_token: + raise GlpiProtocolError("Failed to acquire access token for API request") + return access_token + + +def build_request_headers( + *, + access_token: str | None, + language: str, + glpi_entity: int | None, + glpi_profile: int | None, + entity_recursive: bool, + include_content_type: bool = False, + skip_entity: bool = False, +) -> dict[str, str]: + """Build GLPI request headers from one client state snapshot. + + The header set includes authorisation and language settings, with + optional entity, profile, recursion, and content-type headers derived + from the current client configuration. + """ + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Accept-Language": language, + } + if include_content_type: + headers["Content-Type"] = "application/json" + if not skip_entity: + if glpi_entity is not None: + headers["GLPI-Entity"] = str(glpi_entity) + if glpi_profile is not None: + headers["GLPI-Profile"] = str(glpi_profile) + if entity_recursive: + headers["GLPI-Entity-Recursive"] = "true" + return headers + + +def build_request_url(glpi_api_url: str, endpoint: str) -> str: + """Return the absolute URL for one GLPI endpoint path. + + Callers provide the normalised API base URL and the endpoint suffix + that is already specific to the requested resource. + """ + + return f"{glpi_api_url}/{endpoint}" + + +def finalize_request_response( + response: httpx.Response, + *, + method: str, + url: str, + success_statuses: tuple[int, ...], + logger: logging.Logger, +) -> httpx.Response: + """Validate one GLPI transport response and preserve warning behaviour. + + Server errors are raised immediately while non-success statuses outside + the accepted set are logged for higher-level mutation and lookup helpers + to interpret consistently. + + Raises + ------ + GlpiServerError + When ``response.status_code`` is a 5xx server error. + """ + + method_name = method.upper() + if 500 <= response.status_code < 600: + message = ( + f"GLPI {method_name} {url} failed with " + f"{response.status_code} {response_reason(response)}" + ) + logger.warning(message) + raise GlpiServerError( + message, + status_code=response.status_code, + url=url, + response_text=response.text, + ) + if response.status_code not in success_statuses: + logger.warning( + "GLPI %s %s returned %s: %s", + method_name, + url, + response.status_code, + response.text, + ) + return response + + +def ensure_response_status( + response: httpx.Response, + *, + success_statuses: tuple[int, ...], + failure_message: str, +) -> None: + """Raise a typed :class:`GlpiStatusError` for an unexpected response status. + + Higher-level client methods use this helper to keep their mutation and + fetch failure messages aligned across the per-endpoint mixins. The + raised class narrows to :class:`GlpiAuthError`, :class:`GlpiNotFoundError` + or :class:`GlpiServerError` where the status allows it. + + Raises + ------ + GlpiStatusError + When ``response.status_code`` is outside ``success_statuses``. + """ + + if response.status_code not in success_statuses: + error_class = status_error_class(response.status_code) + raise error_class( + f"{failure_message}: {response.status_code} {response.text}", + status_code=response.status_code, + url=str(response.url), + response_text=response.text, + ) + + +def response_json_or_empty(response: httpx.Response) -> object: + """Return the parsed JSON body or an empty mapping for empty responses. + + Unlike :func:`response_json_mapping` this helper preserves list and + scalar payloads, so it suits callers that may receive either a JSON + object or a JSON array (for example the legacy v1 endpoints). + """ + + if not response.content or not response.text.strip(): + return {} + return response.json() + + +def response_json_mapping(response: httpx.Response) -> Mapping[str, object]: + """Return the JSON response payload as a mapping when possible. + + Empty response bodies become an empty mapping and non-mapping JSON + payloads are intentionally ignored so callers can safely probe for + expected keys. + """ + + result = response.json() if response.content else {} + return result if isinstance(result, Mapping) else {} + + +def require_response_int( + response: httpx.Response, + *, + keys: tuple[str, ...], + missing_message: str, +) -> int: + """Return the first integer field from a JSON response mapping. + + GLPI v2 create responses document numeric identifiers under a small + set of keys. Callers list the candidate keys explicitly so the + behaviour stays predictable. + + Raises + ------ + GlpiProtocolError + When none of ``keys`` maps to an integer value in the response. + """ + + result = response_json_mapping(response) + for key in keys: + value = result.get(key) + if isinstance(value, int) and not isinstance(value, bool): + return value + raise GlpiProtocolError(missing_message) + + +def list_payload_items(payload: object) -> list[dict[str, object]]: + """Return dictionary items from one plain JSON list payload. + + Non-list payloads are treated as empty so callers can safely use this + on responses whose shape may vary or fail validation upstream. + """ + + if not isinstance(payload, list): + return [] + return [item for item in payload if isinstance(item, dict)] + + +def unwrap_timeline_items(payload: object) -> list[dict[str, object]]: + """Return inner records from a GLPI timeline list payload. + + Notes + ----- + The OpenAPI contract documents + ``/Assistance/Ticket/{id}/Timeline/`` GET responses as flat + arrays of the subitem schema, but the live GLPI v2 server actually + returns each entry wrapped in an envelope of the form + ``{"type": "", "item": {...}}``. The helper unwraps that + envelope when present and falls back to the flat shape so it stays + compatible with both behaviours. Per the project rule that real + behaviour wins over the contract, the timeline list helpers call this + helper instead of :func:`list_payload_items`. + """ + + if not isinstance(payload, list): + return [] + items: list[dict[str, object]] = [] + for entry in payload: + if not isinstance(entry, dict): + continue + if "item" in entry and isinstance(entry["item"], dict): + items.append(entry["item"]) + else: + items.append(entry) + return items + + +__all__ = [ + "build_request_headers", + "build_request_url", + "ensure_response_status", + "finalize_request_response", + "list_payload_items", + "request_param_value", + "request_params", + "require_access_token", + "require_response_int", + "response_json_mapping", + "response_json_or_empty", + "transport_error_from", + "unwrap_timeline_items", +] diff --git a/glpi_python_client/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py similarity index 100% rename from glpi_python_client/clients/commons/_payloads.py rename to glpi_python_client/_async/clients/commons/_payloads.py diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py new file mode 100644 index 0000000..7ec4341 --- /dev/null +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -0,0 +1,573 @@ +"""GLPI v2 transport mixin. + +The transport mixin owns token handling, header construction, retries, and +HTTP request dispatch so the per-endpoint mixins under +:mod:`glpi_python_client._async.clients.api` can stay focused on resource-specific +behaviour. + +Concurrency model +----------------- +Access to the auth token manager is serialised with the lock from +:mod:`glpi_python_client._async._concurrency`. That module is one of only +two maintained separately for each surface, because the correct primitive +genuinely differs: an :class:`asyncio.Lock` for concurrent tasks on one +event loop, a :class:`threading.Lock` for a client shared across threads. +Neither substitutes for the other -- see that module for what breaks in +each direction. + +The lock is held only for the short critical section that refreshes the +token. HTTP calls run outside it, so concurrent callers proceed in +parallel while sharing one access token. The underlying HTTP client is +safe for that concurrent use; it is built once at construction and never +mutated afterwards. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar + +import httpx +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed + +from glpi_python_client._async._concurrency import Lock +from glpi_python_client._async.clients.commons._http import ( + build_request_headers, + build_request_url, + ensure_response_status, + finalize_request_response, + list_payload_items, + request_params, + require_access_token, + require_response_int, + transport_error_from, + unwrap_timeline_items, +) +from glpi_python_client._async.clients.commons._payloads import ( + model_from_payload, + model_to_payload, +) +from glpi_python_client._errors import GlpiServerError, GlpiTransportError +from glpi_python_client.models._base import GlpiModel + +if TYPE_CHECKING: + from glpi_python_client._async.auth._v1_session import GLPIV1Session + from glpi_python_client._async.auth.auth import GLPITokenManager + +logger = logging.getLogger(__name__) + +ModelT = TypeVar("ModelT", bound=GlpiModel) + +#: Shared retry policy for every v2 transport verb. +#: +#: Declared once rather than repeated on each of the four verb helpers, and +#: expressed entirely in library-owned exception types. Both parts are +#: deliberate. A predicate that names the HTTP library's own exception base +#: stops matching the moment the transport is swapped — the exception trees of +#: the different libraries are completely disjoint — and retries then vanish +#: with no error, no warning and a green test suite. Naming +#: :class:`~glpi_python_client.GlpiTransportError`, which +#: :func:`~glpi_python_client._async.clients.commons._http.transport_error_from` +#: guarantees every network fault is translated into, makes that failure +#: impossible to reintroduce. +_RETRY_ON_NETWORK_ERRORS = retry( + retry=retry_if_exception_type((GlpiTransportError, GlpiServerError)), + stop=stop_after_attempt(3), + wait=wait_fixed(3), + reraise=True, +) + + +class TransportMixin: + """GLPI API transport helpers shared by the API mixins. + + The class declares the runtime attributes the concrete client owns and + exposes the blocking ``_get_request``, ``_post_request``, + ``_update_request`` and ``_delete_request`` helpers used by every + per-endpoint mixin. + + Thread safety + ------------- + Token acquisition and refresh are serialised by ``_auth_lock`` + (:class:`threading.Lock`) so concurrent threads — whether spawned by + the sync client directly or by the async client through + :func:`asyncio.to_thread` — never race while updating shared + authentication state. HTTP dispatch runs outside the lock and relies + on the thread-safety of :class:`httpx.AsyncClient` for concurrent + calls. + """ + + _auth: GLPITokenManager + _auth_lock: Lock + _closed: bool = False + _session: httpx.AsyncClient + _v1: GLPIV1Session | None + entity_recursive: bool + glpi_api_url: str + glpi_entity: int | None + glpi_profile: int | None + language: str + + def _ensure_open(self) -> None: + """Raise when the client has already been closed. + + All transport helpers call this guard before touching the shared + HTTP session so closed clients fail fast and predictably. + """ + + if self._closed: + raise RuntimeError("GLPI client is closed") + + def _require_v1_session(self, feature: str) -> GLPIV1Session: + """Return the configured v1 session or raise ``RuntimeError``. + + Parameters + ---------- + feature : str + Short label of the caller (for example ``"document upload"`` + or ``"Fields plugin helpers"``) embedded in the error message + so users learn which client option to set. + + Returns + ------- + GLPIV1Session + The legacy v1 session bundled with the client. + + Raises + ------ + RuntimeError + When the client was built without ``v1_base_url`` and + ``v1_user_token``. + """ + + if self._v1 is None: + raise RuntimeError( + f"GLPI {feature} require the legacy v1 session to be configured " + "(set v1_base_url and v1_user_token)." + ) + return self._v1 + + async def _ensure_token(self) -> None: + """Ensure that a valid GLPI access token is available. + + Token refresh is serialised by ``_auth_lock`` so concurrent + callers from any thread never race while updating shared + authentication state. + """ + + self._ensure_open() + async with self._auth_lock: + await self._auth.ensure_token() + + async def _send_request( + self, + method: str, + url: str, + **kwargs: Any, + ) -> httpx.Response: + """Dispatch one blocking HTTP call. + + The helper exists as an indirection seam so tests can stub HTTP + dispatch without monkey-patching the session attribute directly. + + Dispatch goes through ``session.request(method, url, ...)`` rather + than looking up a per-verb attribute, keeping the verb a value + instead of an attribute name. + + Transport-level failures are translated into + :class:`~glpi_python_client.GlpiTransportError` (or + :class:`~glpi_python_client.GlpiTimeoutError`) here, at the single + point where the HTTP library is actually called, so no third-party + exception escapes into the caller's ``except`` clauses. + + Raises + ------ + GlpiTransportError + When the request never produced a response. + """ + + try: + return await self._session.request(method.upper(), url, **kwargs) + except httpx.HTTPError as exc: + raise transport_error_from(exc, method=method, url=url) from exc + + async def _execute_request( + self, + *, + method: str, + endpoint: str, + success_statuses: tuple[int, ...], + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + skip_entity: bool = False, + include_content_type: bool = False, + ) -> httpx.Response: + """Execute one authenticated GLPI request. + + The helper normalises the endpoint URL, headers, timeout, and + payload placement before dispatching the blocking HTTP call. It + guarantees a fresh access token before the request leaves the + process. + """ + + await self._ensure_token() + access_token = require_access_token(self._auth.access_token) + url = build_request_url(self.glpi_api_url, endpoint) + + request_kwargs: dict[str, object] = { + "headers": build_request_headers( + access_token=access_token, + language=self.language, + glpi_entity=self.glpi_entity, + glpi_profile=self.glpi_profile, + entity_recursive=self.entity_recursive, + include_content_type=include_content_type, + skip_entity=skip_entity, + ), + "timeout": 30, + } + if method == "get": + request_kwargs["params"] = request_params(params) + else: + request_kwargs["json"] = json_body + + response = await self._send_request(method, url, **request_kwargs) + return finalize_request_response( + response, + method=method, + url=url, + success_statuses=success_statuses, + logger=logger, + ) + + @_RETRY_ON_NETWORK_ERRORS + async def _get_request( + self, + endpoint: str, + params: dict[str, object] | None = None, + skip_entity: bool = False, + ) -> httpx.Response: + """Execute one authenticated GLPI ``GET`` request. + + Network errors (:class:`~glpi_python_client.GlpiTransportError`) and 5xx + responses (:class:`~glpi_python_client.GlpiServerError`) are + retried up to 3 times, with ``reraise=True`` so the real error + propagates once retries are exhausted; 4xx responses are + returned as-is without a retry. + """ + + return await self._execute_request( + method="get", + endpoint=endpoint, + success_statuses=(200, 206), + params=params, + skip_entity=skip_entity, + ) + + @_RETRY_ON_NETWORK_ERRORS + async def _post_request( + self, + endpoint: str, + json_body: dict[str, object] | None = None, + skip_entity: bool = False, + ) -> httpx.Response: + """Execute one authenticated GLPI ``POST`` request. + + JSON request bodies automatically include the content-type header + needed by the GLPI API. + """ + + return await self._execute_request( + method="post", + endpoint=endpoint, + success_statuses=(200, 201), + json_body=json_body, + skip_entity=skip_entity, + include_content_type=True, + ) + + @_RETRY_ON_NETWORK_ERRORS + async def _update_request( + self, + endpoint: str, + json_body: dict[str, object] | None = None, + ) -> httpx.Response: + """Execute one authenticated GLPI ``PATCH`` request. + + The helper uses the same authenticated execution path as the + other HTTP verbs while targeting the success codes expected from + update calls. + """ + + return await self._execute_request( + method="patch", + endpoint=endpoint, + success_statuses=(200, 204), + json_body=json_body, + include_content_type=True, + ) + + @_RETRY_ON_NETWORK_ERRORS + async def _delete_request( + self, + endpoint: str, + json_body: dict[str, object] | None = None, + skip_entity: bool = False, + ) -> httpx.Response: + """Execute one authenticated GLPI ``DELETE`` request. + + Some delete endpoints accept a JSON body, so the content-type + header is enabled automatically when a body is supplied. + """ + + return await self._execute_request( + method="delete", + endpoint=endpoint, + success_statuses=(200, 204), + json_body=json_body, + skip_entity=skip_entity, + include_content_type=json_body is not None, + ) + + async def _resource_list( + self, + endpoint: str, + model: type[ModelT], + *, + params: dict[str, object] | None = None, + skip_entity: bool = False, + failure_message: str | None = None, + success_statuses: tuple[int, ...] = (200, 206), + unwrap_envelope: bool = False, + ) -> list[ModelT]: + """Run a GLPI list/search request and validate every returned record. + + Parameters + ---------- + endpoint : str + Resource path forwarded to the transport ``GET`` helper. + model : type[ModelT] + Pydantic class used to validate each item from the response. + params : dict[str, object] | None, optional + Query parameters forwarded to the underlying ``GET`` request. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted. + failure_message : str | None, optional + When provided, response status is checked with this message; + search-style endpoints that tolerate empty results pass + ``None``. + success_statuses : tuple[int, ...], optional + HTTP status codes considered successful when + ``failure_message`` is set. + unwrap_envelope : bool, optional + When ``True`` the GLPI timeline ``{"type", "item"}`` envelope + is unwrapped before validation. + + Returns + ------- + list[ModelT] + Validated records returned by the GLPI server. + """ + + response = await self._get_request( + endpoint, params=params, skip_entity=skip_entity + ) + if failure_message is not None: + ensure_response_status( + response, + success_statuses=success_statuses, + failure_message=failure_message, + ) + payload = response.json() + items = ( + unwrap_timeline_items(payload) + if unwrap_envelope + else list_payload_items(payload) + ) + return [model_from_payload(model, item) for item in items] + + async def _resource_get( + self, + endpoint: str, + model: type[ModelT], + *, + failure_message: str, + skip_entity: bool = False, + ) -> ModelT: + """Fetch one record and validate it against ``model``. + + Parameters + ---------- + endpoint : str + Resource path forwarded to the transport ``GET`` helper. + model : type[ModelT] + Pydantic class used to validate the response payload. + failure_message : str + Message embedded in the ``GlpiStatusError`` raised on a + non-success HTTP status. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted. + + Returns + ------- + ModelT + Validated record returned by the GLPI server. + """ + + response = await self._get_request(endpoint, skip_entity=skip_entity) + ensure_response_status( + response, + success_statuses=(200, 206), + failure_message=failure_message, + ) + return model_from_payload(model, response.json()) + + async def _resource_create( + self, + endpoint: str, + body_model: GlpiModel, + *, + failure_message: str, + missing_message: str, + log_message_factory: Callable[[int], str], + id_keys: tuple[str, ...] = ("id",), + skip_entity: bool = False, + ) -> int: + """Create one record and return the identifier assigned by GLPI. + + Parameters + ---------- + endpoint : str + Resource path forwarded to the transport ``POST`` helper. + body_model : GlpiModel + Pydantic body serialised through :func:`model_to_payload`. + failure_message : str + Message embedded in the ``GlpiStatusError`` raised on a + non-success HTTP status. + missing_message : str + Message embedded in the ``GlpiProtocolError`` raised when the + response payload does not contain any of the expected + identifier keys. + log_message_factory : Callable[[int], str] + Callable invoked with the new identifier to build the + ``logger.info`` payload, allowing call sites to embed the + parent context (for example a ticket id). + id_keys : tuple[str, ...], optional + Candidate keys probed in the response when looking up the + identifier of the newly created record. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted. + + Returns + ------- + int + Numeric identifier assigned by the GLPI server. + """ + + response = await self._post_request( + endpoint, model_to_payload(body_model), skip_entity=skip_entity + ) + ensure_response_status( + response, + success_statuses=(200, 201), + failure_message=failure_message, + ) + new_id = require_response_int( + response, keys=id_keys, missing_message=missing_message + ) + logger.info("%s", log_message_factory(new_id)) + return new_id + + async def _resource_update( + self, + endpoint: str, + body_model: GlpiModel, + *, + failure_message: str, + log_message: str, + ) -> None: + """Patch one record and emit the standard log line on success. + + Parameters + ---------- + endpoint : str + Resource path forwarded to the transport ``PATCH`` helper. + body_model : GlpiModel + Partial Pydantic body serialised through + :func:`model_to_payload`. + failure_message : str + Message embedded in the ``GlpiStatusError`` raised on a + non-success HTTP status. + log_message : str + Pre-formatted message logged at ``INFO`` level on success. + + Returns + ------- + None + """ + + response = await self._update_request(endpoint, model_to_payload(body_model)) + ensure_response_status( + response, + success_statuses=(200, 204), + failure_message=failure_message, + ) + logger.info("%s", log_message) + + async def _resource_delete( + self, + endpoint: str, + *, + failure_message: str, + log_message: str, + force: bool | None = None, + delete_model_cls: type[GlpiModel] | None = None, + body: dict[str, object] | None = None, + skip_entity: bool = False, + ) -> None: + """Delete one record and emit the standard log line on success. + + Parameters + ---------- + endpoint : str + Resource path forwarded to the transport ``DELETE`` helper. + failure_message : str + Message embedded in the ``GlpiStatusError`` raised on a + non-success HTTP status. + log_message : str + Pre-formatted message logged at ``INFO`` level on success. + force : bool | None, optional + When ``True`` and ``delete_model_cls`` is provided, a + ``{"force": True}`` body is sent to permanently delete the + record. ``None`` omits the field altogether. + delete_model_cls : type[GlpiModel] | None, optional + Pydantic class instantiated with ``force`` to build the + optional delete body. + body : dict[str, object] | None, optional + Pre-built request body forwarded as-is when supplied. + Mutually exclusive with the ``force``/``delete_model_cls`` + pair. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted. + + Returns + ------- + None + """ + + request_body = body + if request_body is None and delete_model_cls is not None and force is not None: + request_body = model_to_payload(delete_model_cls(force=force)) # type: ignore[call-arg] + response = await self._delete_request( + endpoint, request_body, skip_entity=skip_entity + ) + ensure_response_status( + response, + success_statuses=(200, 204), + failure_message=failure_message, + ) + logger.info("%s", log_message) + + +__all__ = ["TransportMixin"] diff --git a/glpi_python_client/_async/clients/custom/__init__.py b/glpi_python_client/_async/clients/custom/__init__.py new file mode 100644 index 0000000..a0d0e90 --- /dev/null +++ b/glpi_python_client/_async/clients/custom/__init__.py @@ -0,0 +1,23 @@ +"""Higher-level helpers built on top of the API mixins. + +The custom package exposes operations the GLPI API contract does not +advertise directly but which client applications need: the aggregated +ticket-context view and the reporting helpers, both assembled from the +contract-aligned CRUD helpers in +:mod:`glpi_python_client._async.clients.api`. + +Each helper is written once. The fan-out points call ``gather`` from +:mod:`glpi_python_client._async._concurrency`, which runs them +concurrently here and sequentially in the generated tree -- so there is +no second copy of this logic to keep in step. +""" + +from __future__ import annotations + +from glpi_python_client._async.clients.custom._statistics import StatisticsMixin +from glpi_python_client._async.clients.custom._ticket_context import TicketContextMixin + +__all__ = [ + "StatisticsMixin", + "TicketContextMixin", +] diff --git a/glpi_python_client/_async/clients/custom/_statistics.py b/glpi_python_client/_async/clients/custom/_statistics.py new file mode 100644 index 0000000..b8c752e --- /dev/null +++ b/glpi_python_client/_async/clients/custom/_statistics.py @@ -0,0 +1,973 @@ +"""Lightweight statistics helpers built from the API mixins. + +The mixin exposes simple aggregations over ticket and ticket-task results +returned by the contract-aligned helpers in +:mod:`glpi_python_client._async.clients.api`. These operations are intentionally +kept small and do not perform name resolution or rich label formatting; the +caller can correlate the returned numeric identifiers with the dedicated +``search_*`` helpers when required. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import date, timedelta +from typing import TypedDict + +from glpi_python_client._async.clients.commons._filters import ( + rsql_all_filter, + rsql_any_filter, + rsql_contains_filter, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError +from glpi_python_client.models.api_schema._common import ( + IdNameCompletenameRef, + IdNameRef, +) +from glpi_python_client.models.api_schema.assistance._ticket import GetTicket +from glpi_python_client.models.api_schema.assistance.timeline._task import ( + GetTicketTask, +) +from glpi_python_client.models.api_schema.enums import ( + GlpiPriority, + GlpiTicketType, +) + +#: The GLPI v2 ticket search includes soft-deleted ("trashed") tickets by +#: default, while the v1 search excludes them. Every aggregation here is +#: about live work, so the v2 queries pin the flag explicitly. Measured on +#: a live GLPI 11 instance: 59,690 live + 258 trashed = 59,948 unfiltered, +#: and for some users the trashed rows were the large majority of matches. +_LIVE_TICKETS = "is_deleted==false" + +#: v1 ``search/Ticket`` searchOption ids. The v2 API exposes no filterable +#: assignee at all -- its ``team`` array cannot be joined by the RSQL engine +#: (the contract-declared subfields answer HTTP 500 and every other spelling +#: is silently ignored) -- so actor-based selection has to go through v1. +_V1_SO_TICKET_ID = 2 +_V1_SO_REQUESTER = 4 # "Demandeur" -- glpi_tickets_users.users_id, type=1 +_V1_SO_ASSIGNEE = 5 # "Technicien" -- glpi_tickets_users.users_id, type=2 + +#: v1 rejects a ``range`` that starts past the end of the result set with +#: HTTP 400, so paging is bounded by ``totalcount`` rather than by probing. +_V1_SEARCH_PAGE_SIZE = 1000 + +#: Rows fetched per page from the v1 ``TicketTask`` collection. +_V1_TASK_PAGE_SIZE = 1000 + +#: Above this many tickets, one bulk v1 task sweep beats a per-ticket v2 +#: request each. The sweep costs one page per 1000 tasks created since the +#: window opened -- typically one or two -- while the per-ticket path costs +#: exactly ``len(ticket_ids)`` requests. Below the threshold the per-ticket +#: path is cheaper and needs no v1 session, so it stays the default. +_V1_TASK_BULK_THRESHOLD = 25 + + +def _validate_actor_id(value: int, parameter: str) -> int: + """Return ``value`` when it is usable as a GLPI user identifier. + + The v1 search engine fails *open* on a malformed actor value instead of + rejecting it, so a bad id yields a plausible-looking but meaningless + result set rather than an error. Measured on a live instance: + ``equals 0`` matched 20,905 tickets (a LEFT-JOIN-NULL "has no actor" + match), an empty value matched the entire 59,689-ticket baseline, and a + non-numeric value returned the same arbitrary 3 rows whatever the + string. Guarding at the boundary is what keeps this fix from + reintroducing the class of bug it exists to remove. + + Parameters + ---------- + value : int + Candidate GLPI user identifier. + parameter : str + Name of the public parameter, used in the error message. + + Returns + ------- + int + The validated identifier. + + Raises + ------ + GlpiValidationError + When ``value`` is not a positive integer. ``bool`` is rejected + explicitly because it is an ``int`` subclass in Python. + """ + + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise GlpiValidationError( + f"{parameter} must be a positive integer GLPI user id; got " + f"{value!r}. GLPI's v1 search silently returns unrelated rows " + "for 0, empty or non-numeric actor values instead of failing." + ) + return value + + +class TaskStatisticsResult(TypedDict): + """Typed shape returned by :meth:`StatisticsMixin.get_task_statistics`.""" + + ticket_count: int + task_count: int + total_duration: int + duration_by_user: dict[str, int] + duration_by_ticket: dict[int, int] + + +class TaskDurationsResult(TypedDict): + """Typed shape returned by :meth:`StatisticsMixin.get_task_durations`.""" + + start_date: str + end_date: str + total_duration: int + task_count: int + duration_by_user: dict[str, int] + duration_by_entity: dict[str, int] + tasks: list[dict[str, object]] | None + + +class UserActivityEntry(TypedDict): + """One per-user activity bucket inside :class:`UserActivityResult`.""" + + user_ids: list[int] + tickets_as_technician: int + tickets_as_recipient: int + task_durations: TaskDurationsResult + + +class UserActivityResult(TypedDict): + """Typed shape returned by :meth:`StatisticsMixin.get_user_activity`.""" + + users: dict[str, UserActivityEntry] + + +class StatisticsMixin(TransportMixin): + """Custom statistics built on the contract API mixins.""" + + async def _v1_ticket_ids_for_actor( + self, user_id: int, *, search_options: tuple[int, ...], parameter: str + ) -> set[int]: + """Return ids of tickets linking ``user_id`` under any given role. + + Actor selection cannot be expressed in the v2 API, so this reads the + v1 search engine, OR-ing one criterion per requested searchOption. + Unlike v2 -- which silently ignores a filter field it does not know + and answers with the complete unfiltered set -- v1 rejects an + unknown searchOption with HTTP 400, so a mistake here fails loudly. + + Parameters + ---------- + user_id : int + GLPI user identifier; validated by :func:`_validate_actor_id`. + search_options : tuple[int, ...] + v1 searchOption ids to OR together, e.g. + ``(_V1_SO_ASSIGNEE, _V1_SO_REQUESTER)``. + parameter : str + Public parameter name quoted in validation errors. + + Returns + ------- + set[int] + Ticket identifiers visible to the configured v1 session. + + Raises + ------ + GlpiValidationError + When ``user_id`` is not a positive integer. + RuntimeError + When the client has no v1 session configured. + """ + + uid = _validate_actor_id(user_id, parameter) + v1 = self._require_v1_session("actor-based ticket statistics") + + params: dict[str, object] = {"forcedisplay[0]": _V1_SO_TICKET_ID} + for index, option in enumerate(search_options): + if index: + params[f"criteria[{index}][link]"] = "OR" + params[f"criteria[{index}][field]"] = option + params[f"criteria[{index}][searchtype]"] = "equals" + params[f"criteria[{index}][value]"] = uid + + ids: set[int] = set() + start = 0 + while True: + page = dict(params) + page["range"] = f"{start}-{start + _V1_SEARCH_PAGE_SIZE - 1}" + payload = await v1.request_json("GET", "search/Ticket", params=page) + if not isinstance(payload, dict): + break + rows = payload.get("data") + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + raw = row.get(str(_V1_SO_TICKET_ID)) + if isinstance(raw, bool) or not isinstance(raw, (int, str)): + continue + try: + ids.add(int(raw)) + except (TypeError, ValueError): + continue + total = payload.get("totalcount") + # Bound by totalcount: asking for a range that starts past the + # end is an HTTP 400 on this API, not an empty page. + if not isinstance(total, int): + break + start += _V1_SEARCH_PAGE_SIZE + if start >= total: + break + return ids + + async def _v1_task_statistics( + self, ticket_ids: list[int], *, since: date + ) -> TaskStatisticsResult: + """Aggregate tasks for ``ticket_ids`` with one bulk v1 sweep. + + Replaces the per-ticket fan-out for large ticket sets. The v2 API + publishes tasks only under ``/Assistance/Ticket/{id}/Timeline/Task``, + so aggregating N tickets costs N requests; the v1 ``TicketTask`` + collection returns whole rows -- including ``tickets_id`` -- and + pages 1000 at a time. + + Note that v1 ``search/TicketTask`` is *not* usable here: its + searchOptions expose the task's own id, content, category, date, + privacy, technician, duration and state, but no parent ticket id, + so results could not be attributed back to a ticket. + + Rows are swept newest-first and paging stops once a page predates + ``since``. A task cannot be created before the ticket it belongs to, + and every ticket under consideration was created on or after + ``since``, so no relevant task is missed. The upper end is + deliberately unbounded: a ticket created inside the window may still + gain tasks long afterwards. + + The returned aggregate is identical to :meth:`get_task_statistics` + for the same tickets -- v1 ``actiontime`` is v2 ``duration``, and v1 + ``users_id`` is the v2 task ``user`` (the author; the technician + lives in ``users_id_tech``, which v2 does not expose). Rows are + mapped into ``GetTicketTask`` and summarised by the same helper, so + the two paths cannot drift apart. + + Parameters + ---------- + ticket_ids : list[int] + Tickets whose tasks should be aggregated. + since : date + Lower bound on task creation; the start of the caller's window. + + Returns + ------- + TaskStatisticsResult + Same shape and keys as :meth:`get_task_statistics`. + + Raises + ------ + RuntimeError + When the client has no v1 session configured. + """ + + v1 = self._require_v1_session("bulk task statistics") + wanted = set(ticket_ids) + cutoff = since.isoformat() + tasks: list[GetTicketTask] = [] + start = 0 + while True: + payload = await v1.request_json( + "GET", + "TicketTask", + params={ + "range": f"{start}-{start + _V1_TASK_PAGE_SIZE - 1}", + "sort": "date_creation", + "order": "DESC", + }, + ) + if not isinstance(payload, list) or not payload: + break + oldest_seen: str | None = None + for row in payload: + if not isinstance(row, dict): + continue + created = row.get("date_creation") + if isinstance(created, str) and created: + oldest_seen = created + ticket_id = row.get("tickets_id") + if not isinstance(ticket_id, int) or ticket_id not in wanted: + continue + author = row.get("users_id") + duration = row.get("actiontime") + tasks.append( + GetTicketTask( + id=row.get("id") if isinstance(row.get("id"), int) else None, + tickets_id=ticket_id, + duration=duration if isinstance(duration, int) else 0, + user=( + IdNameRef(id=author) + if isinstance(author, int) and author + else None + ), + ) + ) + if len(payload) < _V1_TASK_PAGE_SIZE: + break + if oldest_seen is not None and oldest_seen[:10] < cutoff: + break + start += _V1_TASK_PAGE_SIZE + return _summarize_tasks(ticket_ids, tasks) + + async def get_ticket_statistics( + self, + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + entity_id: int | None = None, + entity_name: str | None = None, + extra_filter: str | None = None, + ) -> dict[str, object]: + """Return ticket counts grouped by entity, status, priority, and type. + + The date window is applied to the GLPI ``date_creation`` field + and results are aggregated locally in Python. Returned + identifiers are the raw GLPI numeric values that callers can + resolve with the dedicated ``search_*`` helpers when human + labels are needed. + + Parameters + ---------- + start_date : str | None, optional + ISO ``YYYY-MM-DD`` start of the window (inclusive from + 00:00:00). Defaults to ``end_date - default_days + 1`` + when omitted. + end_date : str | None, optional + ISO ``YYYY-MM-DD`` end of the window (inclusive through + 23:59:59). Defaults to today. + default_days : int, optional + Span in days used when ``start_date`` is omitted (defaults + to 30 and must be a positive integer). + entity_id : int | None, optional + When provided, restricts results to tickets belonging to the + entity with this GLPI identifier. + entity_name : str | None, optional + When provided (and ``entity_id`` is ``None``), the name is + resolved via ``search_entities`` and the matched entity IDs + are used to filter tickets. If no entity matches, + ``{"entities": {}}`` is returned immediately. + extra_filter : str | None, optional + Optional raw RSQL fragment to ``AND`` with the date window + on the server side. + + Returns + ------- + dict[str, object] + Mapping with one ``entities`` key listing per-entity + aggregates. Each entity bucket exposes ``total``, + ``by_status``, ``by_priority``, and ``by_type`` counters. + + Raises + ------ + GlpiValidationError + If ``default_days < 1``, ``start_date`` / ``end_date`` is not a + valid ISO date, or ``start_date`` is after ``end_date``. + """ + + start, end = _resolve_window( + start_date=start_date, + end_date=end_date, + default_days=default_days, + ) + + entity_filter: str | None = None + if entity_id is not None: + entity_filter = f"entity.id=={entity_id}" + elif entity_name is not None: + name_filter = rsql_contains_filter("name", entity_name) or "" + entities = await self.search_entities( # type: ignore[attr-defined] + rsql_filter=name_filter, + limit=200, + ) + if not entities: + return {"entities": {}} + entity_filter = rsql_any_filter( + *(f"entity.id=={e.id}" for e in entities if e.id is not None) + ) + date_filter = f"date_creation=ge={start.isoformat()};" + date_filter += f"date_creation=le={end.isoformat()} 23:59:59" + query = rsql_all_filter( + date_filter, + entity_filter, + _LIVE_TICKETS, + extra_filter, + ) + tickets: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined] + rsql_filter=query or "", + limit=200, + ) + return _summarize_tickets(tickets) + + async def get_task_statistics( + self, + ticket_ids: list[int], + ) -> TaskStatisticsResult: + """Return task duration totals grouped by user and ticket. + + The helper expects a list of ticket identifiers because GLPI + does not publish a global task collection endpoint. Callers + typically gather the relevant ticket identifiers through + ``search_tickets`` first. + + Parameters + ---------- + ticket_ids : list[int] + Identifiers of the tickets whose tasks should be aggregated. + An empty list returns zeroed totals without any HTTP call. + + Returns + ------- + TaskStatisticsResult + Mapping with ``ticket_count``, ``task_count``, + ``total_duration``, ``duration_by_user``, and + ``duration_by_ticket`` entries (durations are integer + seconds, matching the GLPI ``duration`` field). + """ + + if not ticket_ids: + return TaskStatisticsResult( + ticket_count=0, + task_count=0, + total_duration=0, + duration_by_user={}, + duration_by_ticket={}, + ) + + results: list[list[GetTicketTask]] = [ + await self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined] + for ticket_id in ticket_ids + ] + flattened: list[GetTicketTask] = [task for batch in results for task in batch] + return _summarize_tasks(ticket_ids, flattened) + + async def get_task_durations( + self, + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + entity_id: int | None = None, + entity_name: str | None = None, + user_id: int | None = None, + user_editor_id: int | None = None, + user_recipient_id: int | None = None, + extra_filter: str | None = None, + return_task_details: bool = False, + ) -> TaskDurationsResult: + """Return task duration totals with optional per-task detail. + + Builds an RSQL filter from the supplied parameters, collects all + matching tickets by iterating :meth:`iter_search_tickets`, computes + ``duration_by_entity`` by grouping :meth:`get_task_statistics` + results against the per-ticket entity map, and optionally returns a + flat list of individual task records. + + Parameters + ---------- + start_date : str | None, optional + ISO ``YYYY-MM-DD`` start of the window (inclusive from + 00:00:00). Defaults to ``end_date - default_days + 1`` + when omitted. + end_date : str | None, optional + ISO ``YYYY-MM-DD`` end of the window (inclusive through + 23:59:59). Defaults to today. + default_days : int, optional + Span in days used when ``start_date`` is omitted (defaults + to 30 and must be a positive integer). + entity_id : int | None, optional + Restrict to tickets in this entity. + entity_name : str | None, optional + Resolve entity by name and restrict to matched entities + (ignored when ``entity_id`` is given). + user_id : int | None, optional + Restrict to tickets where the user is an assignee or + requester (OR semantics across both roles). + user_editor_id : int | None, optional + Restrict to tickets last updated by this user. + user_recipient_id : int | None, optional + Restrict to tickets where this user is the requester. + extra_filter : str | None, optional + Optional raw RSQL fragment appended as an AND clause. + return_task_details : bool, optional + When ``True``, include a ``tasks`` list of individual task + records in the returned mapping (default ``False``). + + Returns + ------- + TaskDurationsResult + Mapping with ``start_date``, ``end_date``, ``total_duration``, + ``task_count``, ``duration_by_user``, ``duration_by_entity``, + and ``tasks`` (``None`` when ``return_task_details=False``). + + Raises + ------ + GlpiValidationError + If ``default_days < 1``, ``start_date`` / ``end_date`` is not a + valid ISO date, or ``start_date`` is after ``end_date``. + """ + + start, end = _resolve_window( + start_date=start_date, + end_date=end_date, + default_days=default_days, + ) + date_filter = f"date_creation=ge={start.isoformat()};" + date_filter += f"date_creation=le={end.isoformat()} 23:59:59" + + entity_filter: str | None = None + if entity_id is not None: + entity_filter = f"entity.id=={entity_id}" + elif entity_name is not None: + name_filter = rsql_contains_filter("name", entity_name) or "" + entities = await self.search_entities( # type: ignore[attr-defined] + rsql_filter=name_filter, + limit=200, + ) + if not entities: + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=0, + task_count=0, + duration_by_user={}, + duration_by_entity={}, + tasks=None, + ) + entity_filter = rsql_any_filter( + *(f"entity.id=={e.id}" for e in entities if e.id is not None) + ) + + # ``user_id`` selects on the ticket's actors, which v2 cannot + # express; resolve the id set through v1 and intersect below. + actor_ticket_ids: set[int] | None = None + if user_id is not None: + actor_ticket_ids = await self._v1_ticket_ids_for_actor( + user_id, + search_options=(_V1_SO_ASSIGNEE, _V1_SO_REQUESTER), + parameter="user_id", + ) + if not actor_ticket_ids: + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=0, + task_count=0, + duration_by_user={}, + duration_by_entity={}, + tasks=None, + ) + + editor_filter: str | None = None + if user_editor_id is not None: + editor_filter = f"user_editor.id=={user_editor_id}" + + recipient_filter: str | None = None + if user_recipient_id is not None: + recipient_filter = f"user_recipient.id=={user_recipient_id}" + + rsql_filter = ( + rsql_all_filter( + date_filter, + entity_filter, + editor_filter, + recipient_filter, + _LIVE_TICKETS, + extra_filter, + ) + or "" + ) + + ticket_ids: list[int] = [] + ticket_entity_map: dict[int, str] = {} + for batch in await self.iter_search_tickets( # type: ignore[attr-defined] + rsql_filter, + batch_size=200, + ): + for ticket in batch: + if ticket.id is None: + continue + if actor_ticket_ids is not None and ticket.id not in actor_ticket_ids: + continue + ticket_ids.append(ticket.id) + ticket_entity_map[ticket.id] = _entity_key(ticket.entity) + + # One bulk v1 sweep replaces the per-ticket fan-out once the ticket + # set is big enough to pay for it; the aggregate is identical. + if self._v1 is not None and len(ticket_ids) >= _V1_TASK_BULK_THRESHOLD: + result = await self._v1_task_statistics(ticket_ids, since=start) + else: + result = await self.get_task_statistics(ticket_ids) + duration_by_ticket = result["duration_by_ticket"] + + duration_by_entity: defaultdict[str, int] = defaultdict(int) + for tid, dur in duration_by_ticket.items(): + entity_key = ticket_entity_map.get(int(tid), "unknown") + duration_by_entity[entity_key] += int(dur) + + task_details: list[dict[str, object]] | None = None + if return_task_details: + task_details = [] + for tid, dur in duration_by_ticket.items(): + if int(dur) == 0: + continue + for task in await self.list_ticket_tasks(int(tid)): # type: ignore[attr-defined] + task_details.append( + { + "task_id": task.id, + "ticket_id": int(tid), + "duration": int(task.duration or 0), + "user_id": task.user.id if task.user else None, + "user_name": task.user.name if task.user else None, + "date": str(task.date_creation or ""), + } + ) + + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=int(result["total_duration"]), + task_count=int(result["task_count"]), + duration_by_user=result["duration_by_user"], + duration_by_entity=dict(duration_by_entity), + tasks=task_details, + ) + + async def get_user_activity( + self, + *, + user_id: int | None = None, + username: str | None = None, + realname: str | None = None, + firstname: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + ) -> UserActivityResult: + """Return per-user GLPI activity aggregated across tickets and tasks. + + Aggregates tickets where each matched user is an assignee, tickets + where the user is a requester, and task durations over the requested + date window. When multiple users resolve to the same display key + their results are merged. + + Parameters + ---------- + user_id : int | None, optional + Identify the user by GLPI numeric identifier. + username : str | None, optional + Filter by username (substring match). + realname : str | None, optional + Filter by family name (substring match). + firstname : str | None, optional + Filter by given name (substring match). + start_date : str | None, optional + ISO ``YYYY-MM-DD`` start of the activity window (inclusive + from 00:00:00). + end_date : str | None, optional + ISO ``YYYY-MM-DD`` end of the activity window (inclusive + through 23:59:59). Defaults to today. + default_days : int, optional + Span in days used when ``start_date`` is omitted (default 30). + + Returns + ------- + UserActivityResult + Mapping with one ``users`` key. Each user key maps to a + :class:`UserActivityEntry` with ``user_ids``, + ``tickets_as_technician``, ``tickets_as_recipient``, and + ``task_durations``. + + Raises + ------ + GlpiValidationError + If none of ``user_id``, ``username``, ``realname``, or + ``firstname`` are supplied, or if the supplied criteria match + no GLPI users. + """ + + if all(v is None for v in (user_id, username, realname, firstname)): + raise GlpiValidationError( + "At least one of user_id, username, realname, or " + "firstname must be supplied" + ) + + start, end = _resolve_window( + start_date=start_date, + end_date=end_date, + default_days=default_days, + ) + + if user_id is not None: + resolved_user_ids: list[int] = [user_id] + user_display_map: dict[int, str] = {user_id: str(user_id)} + else: + name_parts = [ + rsql_contains_filter("username", username) if username else None, + rsql_contains_filter("realname", realname) if realname else None, + rsql_contains_filter("firstname", firstname) if firstname else None, + ] + user_rsql = rsql_all_filter(*name_parts) or "" + matched_users = await self.search_users( # type: ignore[attr-defined] + rsql_filter=user_rsql, + limit=200, + ) + if not matched_users: + raise GlpiValidationError("No users matched the supplied criteria") + resolved_user_ids = [u.id for u in matched_users if u.id is not None] + user_display_map = { + u.id: ( + f"{u.firstname or ''} {u.realname or ''}".strip() + or u.username + or str(u.id) + ) + for u in matched_users + if u.id is not None + } + + date_range = f"date_creation=ge={start.isoformat()};" + date_range += f"date_creation=le={end.isoformat()} 23:59:59" + + # The date window is resolved once for every user rather than once + # per user per role. Previously each user drove two full pagings of + # the corpus, and because the actor clause was silently dropped by + # v2 both walks returned the same unfiltered window. + window_filter = rsql_all_filter(date_range, _LIVE_TICKETS) or "" + window_ids: set[int] = set() + for batch in await self.iter_search_tickets( # type: ignore[attr-defined] + window_filter, + batch_size=200, + ): + for ticket in batch: + if ticket.id is not None: + window_ids.add(ticket.id) + + users_output: dict[str, UserActivityEntry] = {} + for uid in resolved_user_ids: + display_key = user_display_map.get(uid, str(uid)) + # Assignee and requester are counted separately, so they are + # resolved as separate v1 id sets rather than one OR-ed query. + tech_count = len( + window_ids + & await self._v1_ticket_ids_for_actor( + uid, search_options=(_V1_SO_ASSIGNEE,), parameter="user_id" + ) + ) + recipient_count = len( + window_ids + & await self._v1_ticket_ids_for_actor( + uid, search_options=(_V1_SO_REQUESTER,), parameter="user_id" + ) + ) + task_dur = await self.get_task_durations( + start_date=start_date, + end_date=end_date, + default_days=default_days, + user_id=uid, + ) + # Drop the optional ``tasks`` payload before storing on the + # per-user entry; the activity summary keeps only aggregated + # counters per user. + task_dur_clean: TaskDurationsResult = TaskDurationsResult( + start_date=task_dur["start_date"], + end_date=task_dur["end_date"], + total_duration=task_dur["total_duration"], + task_count=task_dur["task_count"], + duration_by_user=dict(task_dur["duration_by_user"]), + duration_by_entity=dict(task_dur["duration_by_entity"]), + tasks=None, + ) + + if display_key in users_output: + existing = users_output[display_key] + existing["user_ids"] = [*existing["user_ids"], uid] + existing["tickets_as_technician"] += tech_count + existing["tickets_as_recipient"] += recipient_count + existing["task_durations"] = _merge_task_durations( + existing["task_durations"], task_dur_clean + ) + else: + users_output[display_key] = UserActivityEntry( + user_ids=[uid], + tickets_as_technician=tech_count, + tickets_as_recipient=recipient_count, + task_durations=task_dur_clean, + ) + + return UserActivityResult(users=users_output) + + +def _merge_task_durations( + prev: TaskDurationsResult, new: TaskDurationsResult +) -> TaskDurationsResult: + """Merge two task-duration aggregates summing every counter. + + The returned ``start_date`` / ``end_date`` are inherited from + ``prev`` since the helper is only used to fold per-user results that + were computed over the same window. The ``tasks`` payload is dropped + because the merged aggregate is part of a user activity report and + not a detail listing. + """ + + merged_by_user: dict[str, int] = dict(prev["duration_by_user"]) + for k, v in new["duration_by_user"].items(): + merged_by_user[k] = merged_by_user.get(k, 0) + int(v) + merged_by_entity: dict[str, int] = dict(prev["duration_by_entity"]) + for k, v in new["duration_by_entity"].items(): + merged_by_entity[k] = merged_by_entity.get(k, 0) + int(v) + return TaskDurationsResult( + start_date=prev["start_date"], + end_date=prev["end_date"], + total_duration=prev["total_duration"] + new["total_duration"], + task_count=prev["task_count"] + new["task_count"], + duration_by_user=merged_by_user, + duration_by_entity=merged_by_entity, + tasks=None, + ) + + +def _resolve_window( + *, + start_date: str | None, + end_date: str | None, + default_days: int, +) -> tuple[date, date]: + """Resolve a date window from optional ISO inputs and a default span. + + Validation matches the legacy analytics helper: positive default span, + parsed ISO dates, and ``start <= end``. + + Raises + ------ + GlpiValidationError + If ``default_days < 1``, ``start_date`` / ``end_date`` is not a + valid ISO ``YYYY-MM-DD`` string, or ``start_date`` is after + ``end_date``. + """ + + if default_days < 1: + raise GlpiValidationError("default_days must be a positive integer") + try: + parsed_end = date.fromisoformat(end_date) if end_date else date.today() + except ValueError as exc: + raise GlpiValidationError(f"Invalid end_date: {end_date!r}") from exc + try: + parsed_start = ( + date.fromisoformat(start_date) + if start_date + else parsed_end - timedelta(days=default_days - 1) + ) + except ValueError as exc: + raise GlpiValidationError(f"Invalid start_date: {start_date!r}") from exc + if parsed_start > parsed_end: + raise GlpiValidationError("start_date must be less than or equal to end_date") + return parsed_start, parsed_end + + +def _summarize_tickets(tickets: list[GetTicket]) -> dict[str, object]: + """Group tickets by entity and break each entity down by attribute.""" + + entities: dict[str, dict[str, object]] = defaultdict( + lambda: { + "total": 0, + "by_status": defaultdict(int), + "by_priority": defaultdict(int), + "by_type": defaultdict(int), + } + ) + for ticket in tickets: + entity_key = _entity_key(ticket.entity) + bucket = entities[entity_key] + bucket["total"] = int(bucket["total"]) + 1 # type: ignore[call-overload] + _count_status(bucket["by_status"], ticket.status) # type: ignore[arg-type] + _count_enum(bucket["by_priority"], ticket.priority, GlpiPriority) # type: ignore[arg-type] + _count_enum(bucket["by_type"], ticket.type, GlpiTicketType) # type: ignore[arg-type] + return {"entities": {key: _freeze_bucket(value) for key, value in entities.items()}} + + +def _summarize_tasks( + ticket_ids: list[int], tasks: list[GetTicketTask] +) -> TaskStatisticsResult: + """Aggregate one task list by user and parent ticket identifier.""" + + duration_by_user: defaultdict[str, int] = defaultdict(int) + duration_by_ticket: defaultdict[int, int] = defaultdict(int) + total_duration = 0 + for task in tasks: + duration = int(task.duration or 0) + total_duration += duration + duration_by_user[_user_key(task.user)] += duration + if task.tickets_id is not None: + duration_by_ticket[task.tickets_id] += duration + return TaskStatisticsResult( + ticket_count=len(ticket_ids), + task_count=len(tasks), + total_duration=total_duration, + duration_by_user=dict(duration_by_user), + duration_by_ticket=dict(duration_by_ticket), + ) + + +def _entity_key(entity: IdNameCompletenameRef | None) -> str: + """Return one stable identifier string for the provided entity reference. + + Numeric entity identifiers are preferred so the output stays stable when + the entity name changes between runs. + """ + + if entity is None: + return "unknown" + if entity.id is not None: + return str(entity.id) + return entity.name or "unknown" + + +def _user_key(user: IdNameRef | None) -> str: + """Return one stable identifier string for the provided user reference.""" + + if user is None: + return "unknown" + if user.id is not None: + return str(user.id) + return user.name or "unknown" + + +def _count_status(counter: defaultdict[str, int], status: IdNameRef | None) -> None: + """Increment one status counter using the GLPI numeric identifier.""" + + if status is None: + counter["UNKNOWN"] += 1 + return + counter[str(status.id) if status.id is not None else status.name or "UNKNOWN"] += 1 + + +def _count_enum(counter: defaultdict[str, int], value: object, enum_type: type) -> None: + """Increment one counter using the IntEnum member name when possible.""" + + if value is None: + counter["UNKNOWN"] += 1 + return + try: + counter[enum_type(value).name] += 1 + except ValueError: + counter[str(value)] += 1 + + +def _freeze_bucket(bucket: dict[str, object]) -> dict[str, object]: + """Convert defaultdict counters into plain dicts for the public output.""" + + return { + "total": bucket["total"], + "by_status": dict(bucket["by_status"]), # type: ignore[call-overload] + "by_priority": dict(bucket["by_priority"]), # type: ignore[call-overload] + "by_type": dict(bucket["by_type"]), # type: ignore[call-overload] + } + + +__all__ = ["StatisticsMixin"] diff --git a/glpi_python_client/_async/clients/custom/_ticket_context.py b/glpi_python_client/_async/clients/custom/_ticket_context.py new file mode 100644 index 0000000..88d1152 --- /dev/null +++ b/glpi_python_client/_async/clients/custom/_ticket_context.py @@ -0,0 +1,74 @@ +"""Aggregated ticket context view assembled from API mixins. + +The ticket context mixin composes the public ticket and ticket-timeline +helpers to return a single :class:`GlpiTicketContext` model carrying the +primary ticket together with its timeline records. +""" + +from __future__ import annotations + +from glpi_python_client._async._concurrency import gather +from glpi_python_client._async.clients.commons._constants import GlpiId +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext + + +class TicketContextMixin(TransportMixin): + """Ticket-context aggregation helper. + + The mixin assumes the consuming client also exposes the ticket and + ticket-timeline helpers from :mod:`glpi_python_client._async.clients.api`. + The five underlying calls are executed sequentially; the async + variant under + :mod:`glpi_python_client._async.clients.custom._ticket_context_async` + overrides :meth:`get_ticket_context` to fan them out concurrently. + """ + + async def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext: + """Return one aggregated ticket context view. + + The primary ticket fetch and the four timeline list calls are + issued through ``gather``: concurrently on the async surface, + sequentially on the generated sync one. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the ticket to assemble. + + Returns + ------- + GlpiTicketContext + Aggregated view bundling the primary ticket together with + its tasks, followups, solutions, and timeline document + links. + + Raises + ------ + GlpiStatusError + If any of the underlying GLPI calls returns a non-success + HTTP status. + """ + + # The five reads are independent, so they go through ``gather``: + # concurrently on the async surface, and one after the other on the + # generated sync one, where each argument has already been evaluated + # by the time ``gather`` is entered. One expression, both meanings -- + # which is what lets this method exist exactly once. + ticket, tasks, followups, solutions, documents = await gather( + self.get_ticket(ticket_id), # type: ignore[attr-defined] + self.list_ticket_tasks(ticket_id), # type: ignore[attr-defined] + self.list_ticket_followups(ticket_id), # type: ignore[attr-defined] + self.list_ticket_solutions(ticket_id), # type: ignore[attr-defined] + self.list_ticket_timeline_documents(ticket_id), # type: ignore[attr-defined] + ) + return GlpiTicketContext( + ticket=ticket, + tasks=tasks, + followups=followups, + solutions=solutions, + documents=documents, + ) + + +__all__ = ["TicketContextMixin"] diff --git a/glpi_python_client/_sync/auth/__init__.py b/glpi_python_client/_sync/auth/__init__.py new file mode 100644 index 0000000..7e23ef9 --- /dev/null +++ b/glpi_python_client/_sync/auth/__init__.py @@ -0,0 +1,13 @@ +"""Public authentication exports for the GLPI client package. + +The authentication package owns the OAuth2 token manager used by the +GLPI client and the legacy v1 session wrapper used solely by +the management document upload mixin. +""" + +from __future__ import annotations + +from glpi_python_client._sync.auth._v1_session import GLPIV1Session +from glpi_python_client._sync.auth.auth import GLPITokenManager + +__all__ = ["GLPITokenManager", "GLPIV1Session"] diff --git a/glpi_python_client/auth/_v1_session.py b/glpi_python_client/_sync/auth/_v1_session.py similarity index 98% rename from glpi_python_client/auth/_v1_session.py rename to glpi_python_client/_sync/auth/_v1_session.py index ee85bca..8f4f950 100644 --- a/glpi_python_client/auth/_v1_session.py +++ b/glpi_python_client/_sync/auth/_v1_session.py @@ -43,19 +43,19 @@ import httpx from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed +from glpi_python_client._sync.clients.commons._config import build_http_session +from glpi_python_client._sync.clients.commons._http import ( + ensure_response_status, + finalize_request_response, + response_json_or_empty, + transport_error_from, +) from glpi_python_client._errors import ( GlpiProtocolError, GlpiServerError, GlpiTransportError, GlpiValidationError, ) -from glpi_python_client.clients.commons._config import build_http_session -from glpi_python_client.clients.commons._http import ( - ensure_response_status, - finalize_request_response, - response_json_or_empty, - transport_error_from, -) logger = logging.getLogger(__name__) @@ -280,7 +280,9 @@ def _authenticated_request( ) self._renew_session() request_headers = {**self._headers(), **(headers or {})} - response = self._dispatch(verb, url, headers=request_headers, **kwargs) + response = self._dispatch( + verb, url, headers=request_headers, **kwargs + ) return finalize_request_response( response, method=method, diff --git a/glpi_python_client/auth/auth.py b/glpi_python_client/_sync/auth/auth.py similarity index 81% rename from glpi_python_client/auth/auth.py rename to glpi_python_client/_sync/auth/auth.py index 80491ba..b3eb0e6 100644 --- a/glpi_python_client/auth/auth.py +++ b/glpi_python_client/_sync/auth/auth.py @@ -13,18 +13,83 @@ import httpx from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed +from glpi_python_client._sync.clients.commons._config import build_http_session +from glpi_python_client._sync.clients.commons._http import transport_error_from from glpi_python_client._errors import ( GlpiServerError, GlpiTransportError, GlpiValidationError, status_error_class, ) -from glpi_python_client.clients.commons._config import build_http_session -from glpi_python_client.clients.commons._http import transport_error_from logger = logging.getLogger(__name__) +def validate_credentials( + *, + client_id: str | None, + client_secret: str | None, + username: str | None, + password: str | None, +) -> None: + """Check that the supplied OAuth credential sets are complete. + + GLPI authentication accepts client credentials, user credentials, or + both. Partial pairs are rejected here so the token request path does not + fail later with a less actionable error. + + This is a free function rather than only a method so callers can check a + configuration *before* committing resources to it. That ordering matters: + the client used to build its HTTP session first and unwind it in an + ``except`` clause when validation failed, which is not expressible on the + async surface -- an ``httpx.AsyncClient`` has no synchronous close, and a + constructor cannot await one. Validating up front removes the need to + unwind anything. + + Raises + ------ + GlpiValidationError + If either pair is half-supplied, or if neither pair is supplied. + """ + + missing_client_fields = [ + name + for name, value in { + "client_id": client_id, + "client_secret": client_secret, + }.items() + if value is None + ] + missing_user_fields = [ + name + for name, value in { + "username": username, + "password": password, + }.items() + if value is None + ] + + has_client_fields = len(missing_client_fields) < 2 + has_user_fields = len(missing_user_fields) < 2 + + if has_client_fields and missing_client_fields: + raise GlpiValidationError( + "GLPI OAuth client credentials must include both client_id " + "and client_secret." + ) + if has_user_fields and missing_user_fields: + raise GlpiValidationError( + "GLPI user credentials must include both username and password." + ) + if not (client_id is not None and client_secret is not None) and not ( + username is not None and password is not None + ): + raise GlpiValidationError( + "GLPI authentication requires either client_id/client_secret, " + "username/password, or both." + ) + + class GLPITokenManager: """OAuth2 token manager for the GLPI API. @@ -43,7 +108,7 @@ class GLPITokenManager: password : str | None, optional Password for the password grant flow. Provide it together with ``username``. - session : httpx.Client | None, optional + session : httpx.AsyncClient | None, optional Existing HTTP client to reuse. auth_token_refresh : int | None, optional Maximum token age in seconds before a refresh is attempted. ``None`` @@ -109,47 +174,14 @@ def auth_token_refresh(self) -> int | None: return int(self._auth_token_refresh_interval.total_seconds()) def _validate_credentials(self) -> None: - """Validate that the configured OAuth credential sets are complete. + """Validate that the configured OAuth credential sets are complete.""" - GLPI authentication supports either client credentials, user - credentials, or both together. Partial pairs are rejected here so the - token request path does not fail later with a less actionable error. - """ - - missing_client_fields = [ - name - for name, value in { - "client_id": self._client_id, - "client_secret": self._client_secret, - }.items() - if value is None - ] - missing_user_fields = [ - name - for name, value in { - "username": self._username, - "password": self._password, - }.items() - if value is None - ] - - has_client_fields = len(missing_client_fields) < 2 - has_user_fields = len(missing_user_fields) < 2 - - if has_client_fields and missing_client_fields: - raise GlpiValidationError( - "GLPI OAuth client credentials must include both client_id " - "and client_secret." - ) - if has_user_fields and missing_user_fields: - raise GlpiValidationError( - "GLPI user credentials must include both username and password." - ) - if not self._has_client_credentials and not self._has_user_credentials: - raise GlpiValidationError( - "GLPI authentication requires either client_id/client_secret, " - "username/password, or both." - ) + validate_credentials( + client_id=self._client_id, + client_secret=self._client_secret, + username=self._username, + password=self._password, + ) @property def _has_client_credentials(self) -> bool: diff --git a/glpi_python_client/_sync/clients/__init__.py b/glpi_python_client/_sync/clients/__init__.py new file mode 100644 index 0000000..5a4c21a --- /dev/null +++ b/glpi_python_client/_sync/clients/__init__.py @@ -0,0 +1,17 @@ +"""Client class for one GLPI surface. + +The concrete client composes every per-endpoint mixin from +:mod:`glpi_python_client._async.clients.api`, the aggregated helpers from +:mod:`glpi_python_client._async.clients.custom`, and the transport mixin +from :mod:`glpi_python_client._async.clients.commons`. + +Only one of the two client trees is written by hand; the other is +generated from it. Both expose the same endpoint surface, so the choice +between them is purely about the caller's runtime model. +""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.client import GlpiClient + +__all__ = ["GlpiClient"] diff --git a/glpi_python_client/_sync/clients/_base_client.py b/glpi_python_client/_sync/clients/_base_client.py new file mode 100644 index 0000000..cc140a7 --- /dev/null +++ b/glpi_python_client/_sync/clients/_base_client.py @@ -0,0 +1,177 @@ +"""Shared construction logic for the GLPI client. + +The :class:`_BaseGlpiClient` mixin holds the constructor signature, the +resource-bundle assignment, and the :meth:`from_env` classmethod that +both :class:`~glpi_python_client._async.clients.sync_client.GlpiClient` and +:class:`~glpi_python_client._async.clients.async_client.AsyncGlpiClient` use. +Lifecycle helpers (``close``, ``__enter__``/``__exit__`` versus +``__aenter__``/``__aexit__``) stay on the concrete subclasses because +they differ between the sync and async surfaces. +""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import TYPE_CHECKING + +if sys.version_info >= (3, 11): + from typing import Self +else: # pragma: no cover - fallback for Python 3.10 + from typing_extensions import Self + +from glpi_python_client._sync._concurrency import Lock +from glpi_python_client._sync.clients.commons._config import ( + build_client_env_config, + build_client_resources, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +logger = logging.getLogger(__name__) + + +class _BaseGlpiClient: + """Shared construction helpers for the GLPI client variants. + + The mixin assigns the resource bundle returned by + :func:`build_client_resources` and the header, lock and state + attributes the transport mixin declares. + """ + + def __init__( + self, + *, + glpi_api_url: str, + client_id: str | None = None, + client_secret: str | None = None, + username: str | None = None, + password: str | None = None, + glpi_entity: int | None = None, + glpi_profile: int | None = None, + entity_recursive: bool = False, + language: str = "en_GB", + verify_ssl: bool = True, + auth_token_refresh: int | None = None, + v1_base_url: str | None = None, + v1_user_token: str | None = None, + v1_app_token: str | None = None, + ) -> None: + """Build the shared resources for a GLPI client. + + Parameters + ---------- + glpi_api_url : str + Base URL of the GLPI v2 REST API, e.g. + ``https://glpi.example.com/api.php/v2``. + client_id : str | None, optional + OAuth client identifier used to obtain access tokens. + client_secret : str | None, optional + OAuth client secret paired with ``client_id``. + username : str | None, optional + GLPI account username used for the OAuth password grant. + password : str | None, optional + GLPI account password used for the OAuth password grant. + glpi_entity : int | None, optional + Default ``GLPI-Entity`` header sent with each request. + glpi_profile : int | None, optional + Default ``GLPI-Profile`` header sent with each request. + entity_recursive : bool, optional + When ``True`` the ``GLPI-Entity-Recursive`` header is sent so + entity scope includes child entities. + language : str, optional + Default ``Accept-Language`` header value (e.g. ``"en_GB"``). + verify_ssl : bool, optional + Whether the HTTP session verifies the server certificate. + auth_token_refresh : int | None, optional + Number of seconds before token expiry at which the auth + manager proactively refreshes the access token. + v1_base_url : str | None, optional + Base URL of the legacy GLPI v1 API used as a fallback for + binary document uploads and the Fields plugin endpoints. + v1_user_token : str | None, optional + ``user_token`` for the v1 fallback session. + v1_app_token : str | None, optional + ``app_token`` for the v1 fallback session. + + Raises + ------ + GlpiValidationError + If the supplied configuration is incomplete or invalid (e.g. + missing OAuth credentials together with no v1 fallback). + """ + + resources = build_client_resources( + glpi_api_url=glpi_api_url, + client_name=type(self).__name__, + client_id=client_id, + client_secret=client_secret, + username=username, + password=password, + verify_ssl=verify_ssl, + auth_token_refresh=auth_token_refresh, + v1_base_url=v1_base_url, + v1_user_token=v1_user_token, + v1_app_token=v1_app_token, + ) + self.glpi_api_url = resources.glpi_api_url + self._session = resources.session + self._auth = resources.auth + self._v1 = resources.v1 + self.glpi_entity = glpi_entity + self.glpi_profile = glpi_profile + self.entity_recursive = entity_recursive + self.language = language + self._auth_lock = Lock() + self._closed = False + + @classmethod + def from_env( + cls, + *, + env: Mapping[str, str] | None = None, + prefix: str = "GLPI_", + **overrides: object, + ) -> Self: + """Build a client instance from environment variables. + + The variables follow the conventional ```` naming + (``GLPI_API_URL``, ``GLPI_CLIENT_ID``, ``GLPI_CLIENT_SECRET``, + ``GLPI_USERNAME``, ``GLPI_PASSWORD``, ``GLPI_VERIFY_SSL``, + ``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN``, + ``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE``, + ``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``). + + Parameters + ---------- + env : Mapping[str, str] | None, optional + Mapping the helper reads values from. Defaults to + :data:`os.environ`. + prefix : str, optional + Common prefix shared by every environment variable name. + **overrides : object + Keyword overrides forwarded to :meth:`__init__`; the + keyword overrides are forwarded verbatim. + + Returns + ------- + Self + A fully configured client ready to perform requests. + + Raises + ------ + GlpiValidationError + If the resolved configuration is missing a required field. + """ + + config = build_client_env_config( + prefix=prefix, + env=env if env is not None else os.environ, + overrides=overrides, + ) + return cls(**config) # type: ignore[arg-type] + + +__all__ = ["_BaseGlpiClient"] diff --git a/glpi_python_client/clients/api/__init__.py b/glpi_python_client/_sync/clients/api/__init__.py similarity index 57% rename from glpi_python_client/clients/api/__init__.py rename to glpi_python_client/_sync/clients/api/__init__.py index 4f7237b..91e9107 100644 --- a/glpi_python_client/clients/api/__init__.py +++ b/glpi_python_client/_sync/clients/api/__init__.py @@ -1,45 +1,41 @@ """Per-endpoint API mixins backed by the ``api_schema`` Pydantic models. The mixins under this package mirror the endpoints documented in -``docs/glpi_api_contract.json`` one for one. They wrap the Synchronous -transport helpers from :mod:`glpi_python_client.clients.commons` and exchange +``docs/glpi_api_contract.json`` one for one. They wrap the +transport helpers from :mod:`glpi_python_client._async.clients.commons` and exchange typed ``Get``, ``Post``, ``Patch``, and ``Delete`` models with the GLPI API. """ from __future__ import annotations -from glpi_python_client.clients.api.administration import ( +from glpi_python_client._sync.clients.api.administration import ( EntityMixin, UserMixin, ) -from glpi_python_client.clients.api.assistance import ( +from glpi_python_client._sync.clients.api.assistance import ( TeamMemberMixin, TicketMixin, ) -from glpi_python_client.clients.api.assistance.timeline import ( +from glpi_python_client._sync.clients.api.assistance.timeline import ( FollowupMixin, SolutionMixin, TicketTaskMixin, TimelineDocumentMixin, ) -from glpi_python_client.clients.api.dropdowns import LocationMixin -from glpi_python_client.clients.api.knowledgebase import ( - AsyncKBArticleMixin, +from glpi_python_client._sync.clients.api.dropdowns import LocationMixin +from glpi_python_client._sync.clients.api.knowledgebase import ( KBArticleCommentMixin, KBArticleMixin, KBArticleRevisionMixin, KBCategoryMixin, ) -from glpi_python_client.clients.api.management import DocumentMixin -from glpi_python_client.clients.api.plugins import ( - AsyncPluginFieldsMixin, +from glpi_python_client._sync.clients.api.management import DocumentMixin +from glpi_python_client._sync.clients.api.plugins import ( PluginFieldsMixin, ) __all__ = [ - "AsyncKBArticleMixin", - "AsyncPluginFieldsMixin", "DocumentMixin", "EntityMixin", "FollowupMixin", diff --git a/glpi_python_client/_sync/clients/api/administration/__init__.py b/glpi_python_client/_sync/clients/api/administration/__init__.py new file mode 100644 index 0000000..2194c3e --- /dev/null +++ b/glpi_python_client/_sync/clients/api/administration/__init__.py @@ -0,0 +1,13 @@ +"""GLPI ``/Administration`` mixins for the GLPI client. + +The submodules expose the user and entity mixins used by +:class:`glpi_python_client._async.clients.sync_client.GlpiClient` and +:class:`glpi_python_client._async.clients.async_client.AsyncGlpiClient`. +""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.administration._entity import EntityMixin +from glpi_python_client._sync.clients.api.administration._user import UserMixin + +__all__ = ["EntityMixin", "UserMixin"] diff --git a/glpi_python_client/clients/api/administration/_entity.py b/glpi_python_client/_sync/clients/api/administration/_entity.py similarity index 94% rename from glpi_python_client/clients/api/administration/_entity.py rename to glpi_python_client/_sync/clients/api/administration/_entity.py index b0dad3b..3301c4e 100644 --- a/glpi_python_client/clients/api/administration/_entity.py +++ b/glpi_python_client/_sync/clients/api/administration/_entity.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Administration/Entity`` mixin. +"""GLPI ``/Administration/Entity`` mixin. The mixin exposes the search, fetch, create, update, and delete helpers for the GLPI entity resource. Entity calls intentionally bypass the @@ -9,8 +9,8 @@ from collections.abc import Iterator -from glpi_python_client.clients.commons._constants import ENTITY_ENDPOINT, GlpiId -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._constants import ENTITY_ENDPOINT, GlpiId +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.administration._entity import ( DeleteEntity, GetEntity, @@ -20,7 +20,7 @@ class EntityMixin(TransportMixin): - """Synchronous CRUD helpers for ``/Administration/Entity``.""" + """CRUD helpers for ``/Administration/Entity``.""" def search_entities( self, @@ -181,7 +181,9 @@ def update_entity(self, entity_id: GlpiId, entity: PatchEntity) -> None: log_message=f"GLPI API updated entity {entity_id}", ) - def delete_entity(self, entity_id: GlpiId, *, force: bool | None = None) -> None: + def delete_entity( + self, entity_id: GlpiId, *, force: bool | None = None + ) -> None: """Delete one GLPI entity by identifier. Parameters diff --git a/glpi_python_client/clients/api/administration/_user.py b/glpi_python_client/_sync/clients/api/administration/_user.py similarity index 95% rename from glpi_python_client/clients/api/administration/_user.py rename to glpi_python_client/_sync/clients/api/administration/_user.py index c81cf98..2a9cb19 100644 --- a/glpi_python_client/clients/api/administration/_user.py +++ b/glpi_python_client/_sync/clients/api/administration/_user.py @@ -1,17 +1,17 @@ -"""Synchronous GLPI ``/Administration/User`` mixin. +"""GLPI ``/Administration/User`` mixin. The mixin exposes search, fetch, create, update, and delete helpers for the GLPI user resource. All operations exchange the :mod:`glpi_python_client.models.api_schema.administration` models and rely on -the Synchronous transport mixin for HTTP dispatch. +the transport mixin for HTTP dispatch. """ from __future__ import annotations from collections.abc import Iterator -from glpi_python_client.clients.commons._constants import USER_ENDPOINT, GlpiId -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._constants import USER_ENDPOINT, GlpiId +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.administration._user import ( DeleteUser, GetUser, @@ -21,7 +21,7 @@ class UserMixin(TransportMixin): - """Synchronous CRUD helpers for ``/Administration/User``. + """CRUD helpers for ``/Administration/User``. The helpers follow the contract-first naming convention and forward all server-side validation to the GLPI API instead of duplicating checks on diff --git a/glpi_python_client/_sync/clients/api/assistance/__init__.py b/glpi_python_client/_sync/clients/api/assistance/__init__.py new file mode 100644 index 0000000..50f44db --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/__init__.py @@ -0,0 +1,8 @@ +"""GLPI ``/Assistance`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.assistance._team import TeamMemberMixin +from glpi_python_client._sync.clients.api.assistance._ticket import TicketMixin + +__all__ = ["TeamMemberMixin", "TicketMixin"] diff --git a/glpi_python_client/clients/api/assistance/_team.py b/glpi_python_client/_sync/clients/api/assistance/_team.py similarity index 86% rename from glpi_python_client/clients/api/assistance/_team.py rename to glpi_python_client/_sync/clients/api/assistance/_team.py index 059e20c..4247412 100644 --- a/glpi_python_client/clients/api/assistance/_team.py +++ b/glpi_python_client/_sync/clients/api/assistance/_team.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin. +"""GLPI ``/Assistance/Ticket/{id}/TeamMember`` mixin. The team-member endpoint exposes list, add, and remove operations on a ticket. The mixin uses the ``api_schema`` ``TeamMember`` models and lets @@ -9,14 +9,14 @@ import logging -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( TEAM_MEMBER_SUFFIX, TICKET_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._http import ensure_response_status -from glpi_python_client.clients.commons._payloads import model_to_payload -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._http import ensure_response_status +from glpi_python_client._sync.clients.commons._payloads import model_to_payload +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance._team import ( GetTeamMember, PostTeamMember, @@ -26,7 +26,7 @@ class TeamMemberMixin(TransportMixin): - """Synchronous helpers for the ticket team-member endpoint.""" + """Helpers for the ticket team-member endpoint.""" def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]: """List the team members currently linked to one ticket. @@ -53,7 +53,9 @@ def list_ticket_team_members(self, ticket_id: GlpiId) -> list[GetTeamMember]: failure_message=f"Failed to list ticket team members for {ticket_id}", ) - def add_ticket_team_member(self, ticket_id: GlpiId, member: PostTeamMember) -> None: + def add_ticket_team_member( + self, ticket_id: GlpiId, member: PostTeamMember + ) -> None: """Add one team member to a ticket. Parameters diff --git a/glpi_python_client/clients/api/assistance/_ticket.py b/glpi_python_client/_sync/clients/api/assistance/_ticket.py similarity index 95% rename from glpi_python_client/clients/api/assistance/_ticket.py rename to glpi_python_client/_sync/clients/api/assistance/_ticket.py index 575fca7..546d6ea 100644 --- a/glpi_python_client/clients/api/assistance/_ticket.py +++ b/glpi_python_client/_sync/clients/api/assistance/_ticket.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Assistance/Ticket`` mixin. +"""GLPI ``/Assistance/Ticket`` mixin. The mixin exposes search, fetch, create, update, and delete helpers for the GLPI ticket resource using the ``api_schema`` Pydantic models. @@ -8,8 +8,8 @@ from collections.abc import Iterator -from glpi_python_client.clients.commons._constants import TICKET_ENDPOINT, GlpiId -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._constants import TICKET_ENDPOINT, GlpiId +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance._ticket import ( DeleteTicket, GetTicket, @@ -19,7 +19,7 @@ class TicketMixin(TransportMixin): - """Synchronous CRUD helpers for ``/Assistance/Ticket``. + """CRUD helpers for ``/Assistance/Ticket``. The helpers exchange the contract-aligned ``GetTicket``, ``PostTicket``, ``PatchTicket``, and ``DeleteTicket`` models with the GLPI API and let the @@ -202,7 +202,9 @@ def update_ticket(self, ticket_id: GlpiId, ticket: PatchTicket) -> None: log_message=f"GLPI API updated ticket {ticket_id}", ) - def delete_ticket(self, ticket_id: GlpiId, *, force: bool | None = None) -> None: + def delete_ticket( + self, ticket_id: GlpiId, *, force: bool | None = None + ) -> None: """Delete one GLPI ticket by identifier. Parameters diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py b/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py new file mode 100644 index 0000000..ef7c1a8 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py @@ -0,0 +1,23 @@ +"""GLPI ticket-timeline mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.assistance.timeline._document import ( + TimelineDocumentMixin, +) +from glpi_python_client._sync.clients.api.assistance.timeline._followup import ( + FollowupMixin, +) +from glpi_python_client._sync.clients.api.assistance.timeline._solution import ( + SolutionMixin, +) +from glpi_python_client._sync.clients.api.assistance.timeline._task import ( + TicketTaskMixin, +) + +__all__ = [ + "FollowupMixin", + "SolutionMixin", + "TicketTaskMixin", + "TimelineDocumentMixin", +] diff --git a/glpi_python_client/clients/api/assistance/timeline/_document.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py similarity index 94% rename from glpi_python_client/clients/api/assistance/timeline/_document.py rename to glpi_python_client/_sync/clients/api/assistance/timeline/_document.py index fe98f9b..7d93352 100644 --- a/glpi_python_client/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin. +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Document`` mixin. The mixin exposes list, fetch, link, and unlink helpers for the timeline document endpoint that links existing GLPI documents to a ticket. @@ -17,12 +17,12 @@ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( TICKET_ENDPOINT, TIMELINE_DOCUMENT_SUFFIX, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance.timeline._document import ( DeleteTimelineDocument, PatchTimelineDocument, @@ -32,9 +32,11 @@ class TimelineDocumentMixin(TransportMixin): - """Synchronous CRUD helpers for the ticket document timeline endpoint.""" + """CRUD helpers for the ticket document timeline endpoint.""" - def list_ticket_timeline_documents(self, ticket_id: GlpiId) -> list[GetDocument]: + def list_ticket_timeline_documents( + self, ticket_id: GlpiId + ) -> list[GetDocument]: """List all documents linked to one ticket timeline. Parameters diff --git a/glpi_python_client/clients/api/assistance/timeline/_followup.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py similarity index 92% rename from glpi_python_client/clients/api/assistance/timeline/_followup.py rename to glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py index 8ad722c..209e7b2 100644 --- a/glpi_python_client/clients/api/assistance/timeline/_followup.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin. +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Followup`` mixin. The mixin exposes list, fetch, create, update, and delete helpers for the ticket followup timeline endpoint, exchanging the ``api_schema`` followup @@ -11,18 +11,18 @@ the OpenAPI contract documents a flat array of ``ITILFollowup``. Real behaviour wins over the contract, so :func:`list_ticket_followups` unwraps the envelope via the shared -:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list` +:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` helper and tolerates both shapes. """ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( FOLLOWUP_SUFFIX, TICKET_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance.timeline._followup import ( DeleteFollowup, GetFollowup, @@ -32,7 +32,7 @@ class FollowupMixin(TransportMixin): - """Synchronous CRUD helpers for the ticket followup timeline endpoint.""" + """CRUD helpers for the ticket followup timeline endpoint.""" def list_ticket_followups(self, ticket_id: GlpiId) -> list[GetFollowup]: """List all followups linked to one ticket. @@ -87,7 +87,9 @@ def get_ticket_followup( ), ) - def create_ticket_followup(self, ticket_id: GlpiId, followup: PostFollowup) -> int: + def create_ticket_followup( + self, ticket_id: GlpiId, followup: PostFollowup + ) -> int: """Create one followup on a ticket. Parameters diff --git a/glpi_python_client/clients/api/assistance/timeline/_solution.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py similarity index 92% rename from glpi_python_client/clients/api/assistance/timeline/_solution.py rename to glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py index b2dd766..0308347 100644 --- a/glpi_python_client/clients/api/assistance/timeline/_solution.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin. +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Solution`` mixin. The mixin exposes list, fetch, create, update, and delete helpers for the ticket solution timeline endpoint using the ``api_schema`` solution models. @@ -10,18 +10,18 @@ the OpenAPI contract documents a flat array of ``ITILSolution``. Real behaviour wins over the contract, so :func:`list_ticket_solutions` unwraps the envelope through the shared -:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list` +:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` helper and tolerates both shapes. """ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( SOLUTION_SUFFIX, TICKET_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance.timeline._solution import ( DeleteSolution, GetSolution, @@ -31,7 +31,7 @@ class SolutionMixin(TransportMixin): - """Synchronous CRUD helpers for the ticket solution timeline endpoint.""" + """CRUD helpers for the ticket solution timeline endpoint.""" def list_ticket_solutions(self, ticket_id: GlpiId) -> list[GetSolution]: """List all solutions linked to one ticket. @@ -86,7 +86,9 @@ def get_ticket_solution( ), ) - def create_ticket_solution(self, ticket_id: GlpiId, solution: PostSolution) -> int: + def create_ticket_solution( + self, ticket_id: GlpiId, solution: PostSolution + ) -> int: """Create one solution on a ticket. Parameters diff --git a/glpi_python_client/clients/api/assistance/timeline/_task.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py similarity index 92% rename from glpi_python_client/clients/api/assistance/timeline/_task.py rename to glpi_python_client/_sync/clients/api/assistance/timeline/_task.py index 004bd90..efcb571 100644 --- a/glpi_python_client/clients/api/assistance/timeline/_task.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin. +"""GLPI ``/Assistance/Ticket/{id}/Timeline/Task`` mixin. The mixin exposes list, fetch, create, update, and delete helpers for the ticket task timeline endpoint using the contract-aligned ``api_schema`` @@ -11,18 +11,18 @@ OpenAPI contract documents a flat array of ``TicketTask``. Real behaviour wins over the contract, so :func:`list_ticket_tasks` unwraps the envelope through the shared -:meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list` +:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` helper and tolerates both shapes. """ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( TASK_SUFFIX, TICKET_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance.timeline._task import ( DeleteTicketTask, GetTicketTask, @@ -32,7 +32,7 @@ class TicketTaskMixin(TransportMixin): - """Synchronous CRUD helpers for the ticket task timeline endpoint.""" + """CRUD helpers for the ticket task timeline endpoint.""" def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]: """List all tasks linked to one ticket. @@ -56,7 +56,9 @@ def list_ticket_tasks(self, ticket_id: GlpiId) -> list[GetTicketTask]: unwrap_envelope=True, ) - def get_ticket_task(self, ticket_id: GlpiId, task_id: GlpiId) -> GetTicketTask: + def get_ticket_task( + self, ticket_id: GlpiId, task_id: GlpiId + ) -> GetTicketTask: """Fetch one ticket task by identifier. Parameters diff --git a/glpi_python_client/_sync/clients/api/dropdowns/__init__.py b/glpi_python_client/_sync/clients/api/dropdowns/__init__.py new file mode 100644 index 0000000..c7f1cb4 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/dropdowns/__init__.py @@ -0,0 +1,7 @@ +"""GLPI ``/Dropdowns`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.dropdowns._location import LocationMixin + +__all__ = ["LocationMixin"] diff --git a/glpi_python_client/clients/api/dropdowns/_location.py b/glpi_python_client/_sync/clients/api/dropdowns/_location.py similarity index 92% rename from glpi_python_client/clients/api/dropdowns/_location.py rename to glpi_python_client/_sync/clients/api/dropdowns/_location.py index b4caa57..99c1f1f 100644 --- a/glpi_python_client/clients/api/dropdowns/_location.py +++ b/glpi_python_client/_sync/clients/api/dropdowns/_location.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Dropdowns/Location`` mixin. +"""GLPI ``/Dropdowns/Location`` mixin. The mixin exposes search, fetch, create, update, and delete helpers for the GLPI location dropdown resource using the contract-aligned ``api_schema`` @@ -7,8 +7,11 @@ from __future__ import annotations -from glpi_python_client.clients.commons._constants import LOCATION_ENDPOINT, GlpiId -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._constants import ( + LOCATION_ENDPOINT, + GlpiId, +) +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.dropdowns._location import ( DeleteLocation, GetLocation, @@ -18,7 +21,7 @@ class LocationMixin(TransportMixin): - """Synchronous CRUD helpers for ``/Dropdowns/Location``.""" + """CRUD helpers for ``/Dropdowns/Location``.""" def search_locations( self, @@ -103,7 +106,9 @@ def create_location(self, location: PostLocation) -> int: log_message_factory=lambda new_id: f"GLPI API created location {new_id}", ) - def update_location(self, location_id: GlpiId, location: PatchLocation) -> None: + def update_location( + self, location_id: GlpiId, location: PatchLocation + ) -> None: """Update one GLPI location with a partial body. Parameters diff --git a/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py b/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py new file mode 100644 index 0000000..7c13017 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py @@ -0,0 +1,21 @@ +"""GLPI ``/Knowledgebase`` mixins.""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.knowledgebase._article import KBArticleMixin +from glpi_python_client._sync.clients.api.knowledgebase._category import ( + KBCategoryMixin, +) +from glpi_python_client._sync.clients.api.knowledgebase._comment import ( + KBArticleCommentMixin, +) +from glpi_python_client._sync.clients.api.knowledgebase._revision import ( + KBArticleRevisionMixin, +) + +__all__ = [ + "KBArticleCommentMixin", + "KBArticleMixin", + "KBArticleRevisionMixin", + "KBCategoryMixin", +] diff --git a/glpi_python_client/clients/api/knowledgebase/_article.py b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py similarity index 94% rename from glpi_python_client/clients/api/knowledgebase/_article.py rename to glpi_python_client/_sync/clients/api/knowledgebase/_article.py index 17b0ae9..fe1bc4d 100644 --- a/glpi_python_client/clients/api/knowledgebase/_article.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_article.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Knowledgebase/Article`` mixin. +"""GLPI ``/Knowledgebase/Article`` mixin. The mixin exposes search, fetch, create, update, and delete helpers for the GLPI knowledge base article resource using the contract-aligned @@ -10,12 +10,12 @@ from collections.abc import Sequence -from glpi_python_client._errors import GlpiValidationError -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( KB_ARTICLE_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema._common import IdNameRef from glpi_python_client.models.api_schema.knowledgebase._article import ( DeleteKBArticle, @@ -28,7 +28,7 @@ class KBArticleMixin(TransportMixin): - """Synchronous CRUD helpers for ``/Knowledgebase/Article``.""" + """CRUD helpers for ``/Knowledgebase/Article``.""" def search_kb_articles( self, @@ -68,7 +68,9 @@ def search_kb_articles( params["sort"] = sort if language: params["language"] = language - return self._resource_list(KB_ARTICLE_ENDPOINT, GetKBArticle, params=params) + return self._resource_list( + KB_ARTICLE_ENDPOINT, GetKBArticle, params=params + ) def get_kb_article(self, article_id: GlpiId) -> GetKBArticle: """Fetch one knowledge base article by identifier. @@ -118,7 +120,9 @@ def create_kb_article(self, article: PostKBArticle) -> int: ) from exc return new_id - def update_kb_article(self, article_id: GlpiId, article: PatchKBArticle) -> None: + def update_kb_article( + self, article_id: GlpiId, article: PatchKBArticle + ) -> None: """Update one knowledge base article with a partial body. When ``article.categories`` is provided — including an empty list to diff --git a/glpi_python_client/clients/api/knowledgebase/_category.py b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py similarity index 91% rename from glpi_python_client/clients/api/knowledgebase/_category.py rename to glpi_python_client/_sync/clients/api/knowledgebase/_category.py index 5479f33..3e597bc 100644 --- a/glpi_python_client/clients/api/knowledgebase/_category.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_category.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Knowledgebase/Category`` mixin. +"""GLPI ``/Knowledgebase/Category`` mixin. The mixin exposes search, fetch, create, update, and delete helpers for the GLPI knowledge base category resource using the contract-aligned @@ -7,11 +7,11 @@ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( KB_CATEGORY_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.knowledgebase._category import ( DeleteKBCategory, GetKBCategory, @@ -21,7 +21,7 @@ class KBCategoryMixin(TransportMixin): - """Synchronous CRUD helpers for ``/Knowledgebase/Category``.""" + """CRUD helpers for ``/Knowledgebase/Category``.""" def search_kb_categories( self, @@ -61,7 +61,9 @@ def search_kb_categories( params["sort"] = sort if language: params["language"] = language - return self._resource_list(KB_CATEGORY_ENDPOINT, GetKBCategory, params=params) + return self._resource_list( + KB_CATEGORY_ENDPOINT, GetKBCategory, params=params + ) def get_kb_category(self, category_id: GlpiId) -> GetKBCategory: """Fetch one knowledge base category by identifier. diff --git a/glpi_python_client/clients/api/knowledgebase/_comment.py b/glpi_python_client/_sync/clients/api/knowledgebase/_comment.py similarity index 90% rename from glpi_python_client/clients/api/knowledgebase/_comment.py rename to glpi_python_client/_sync/clients/api/knowledgebase/_comment.py index 62dfa03..7b26411 100644 --- a/glpi_python_client/clients/api/knowledgebase/_comment.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_comment.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Knowledgebase/Article/{id}/Comment`` mixin. +"""GLPI ``/Knowledgebase/Article/{id}/Comment`` mixin. The mixin exposes list, fetch, create, update, and delete helpers for the GLPI knowledge base article comment endpoint using the contract-aligned @@ -7,12 +7,12 @@ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( KB_ARTICLE_ENDPOINT, KB_COMMENT_SUFFIX, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.knowledgebase._comment import ( DeleteKBArticleComment, GetKBArticleComment, @@ -22,9 +22,11 @@ class KBArticleCommentMixin(TransportMixin): - """Synchronous CRUD helpers for KB article comments.""" + """CRUD helpers for KB article comments.""" - def list_kb_article_comments(self, article_id: GlpiId) -> list[GetKBArticleComment]: + def list_kb_article_comments( + self, article_id: GlpiId + ) -> list[GetKBArticleComment]: """List every comment attached to one knowledge base article.""" return self._resource_list( diff --git a/glpi_python_client/clients/api/knowledgebase/_revision.py b/glpi_python_client/_sync/clients/api/knowledgebase/_revision.py similarity index 89% rename from glpi_python_client/clients/api/knowledgebase/_revision.py rename to glpi_python_client/_sync/clients/api/knowledgebase/_revision.py index 84fcfe4..8ac001c 100644 --- a/glpi_python_client/clients/api/knowledgebase/_revision.py +++ b/glpi_python_client/_sync/clients/api/knowledgebase/_revision.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Knowledgebase/Article/{id}/Revision`` mixin. +"""GLPI ``/Knowledgebase/Article/{id}/Revision`` mixin. Revisions are read-only. The GLPI contract exposes both a default-language listing (``.../Revision``) and a language-scoped listing @@ -8,19 +8,19 @@ from __future__ import annotations -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( KB_ARTICLE_ENDPOINT, KB_REVISION_SUFFIX, GlpiId, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.knowledgebase._revision import ( GetKBArticleRevision, ) class KBArticleRevisionMixin(TransportMixin): - """Synchronous read helpers for KB article revisions.""" + """Read helpers for KB article revisions.""" def _revision_base(self, article_id: GlpiId, language: str | None) -> str: """Return the revision collection path, language-scoped when given.""" diff --git a/glpi_python_client/_sync/clients/api/management/__init__.py b/glpi_python_client/_sync/clients/api/management/__init__.py new file mode 100644 index 0000000..18a180c --- /dev/null +++ b/glpi_python_client/_sync/clients/api/management/__init__.py @@ -0,0 +1,7 @@ +"""GLPI ``/Management`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.management._document import DocumentMixin + +__all__ = ["DocumentMixin"] diff --git a/glpi_python_client/clients/api/management/_document.py b/glpi_python_client/_sync/clients/api/management/_document.py similarity index 93% rename from glpi_python_client/clients/api/management/_document.py rename to glpi_python_client/_sync/clients/api/management/_document.py index b9cee40..2dfe73e 100644 --- a/glpi_python_client/clients/api/management/_document.py +++ b/glpi_python_client/_sync/clients/api/management/_document.py @@ -1,4 +1,4 @@ -"""Synchronous GLPI ``/Management/Document`` mixin. +"""GLPI ``/Management/Document`` mixin. The mixin exposes JSON metadata CRUD operations on the document resource and a multipart upload helper that delegates to the legacy v1 session because @@ -9,13 +9,13 @@ import logging -from glpi_python_client._errors import GlpiValidationError -from glpi_python_client.clients.commons._constants import ( +from glpi_python_client._sync.clients.commons._constants import ( DOCUMENT_ENDPOINT, GlpiId, ) -from glpi_python_client.clients.commons._http import ensure_response_status -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._http import ensure_response_status +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema.management._document import ( DeleteDocument, GetDocument, @@ -27,7 +27,7 @@ class DocumentMixin(TransportMixin): - """Synchronous CRUD and upload helpers for ``/Management/Document``.""" + """CRUD and upload helpers for ``/Management/Document``.""" def search_documents( self, @@ -122,7 +122,9 @@ def create_document(self, document: PostDocument) -> int: skip_entity=True, ) - def update_document(self, document_id: GlpiId, document: PatchDocument) -> None: + def update_document( + self, document_id: GlpiId, document: PatchDocument + ) -> None: """Update one GLPI document with a partial body. Parameters @@ -226,9 +228,9 @@ def upload_document( Document uploads use the legacy v1 multipart endpoint because the GLPI v2 API does not advertise a binary upload route. The - async :class:`~glpi_python_client.clients.AsyncGlpiClient` + async :class:`~glpi_python_client._async.clients.AsyncGlpiClient` offloads this blocking call to a worker thread automatically; - callers using the sync :class:`~glpi_python_client.clients.GlpiClient` + callers using the sync :class:`~glpi_python_client._async.clients.GlpiClient` invoke it directly. Parameters diff --git a/glpi_python_client/_sync/clients/api/plugins/__init__.py b/glpi_python_client/_sync/clients/api/plugins/__init__.py new file mode 100644 index 0000000..dfbd0bb --- /dev/null +++ b/glpi_python_client/_sync/clients/api/plugins/__init__.py @@ -0,0 +1,10 @@ +"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API. + +Plugins are not advertised in the v2 OpenAPI contract so the mixins +under this package go through the v1 session helper exposed by +:class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`. +""" + +from glpi_python_client._sync.clients.api.plugins._fields import PluginFieldsMixin + +__all__ = ["PluginFieldsMixin"] diff --git a/glpi_python_client/clients/api/plugins/_fields.py b/glpi_python_client/_sync/clients/api/plugins/_fields.py similarity index 96% rename from glpi_python_client/clients/api/plugins/_fields.py rename to glpi_python_client/_sync/clients/api/plugins/_fields.py index 6993725..326514d 100644 --- a/glpi_python_client/clients/api/plugins/_fields.py +++ b/glpi_python_client/_sync/clients/api/plugins/_fields.py @@ -1,9 +1,9 @@ -"""Synchronous client mixin for the GLPI ``Fields`` plugin. +"""Client mixin for the GLPI ``Fields`` plugin. The `Fields plugin `_ adds user-defined custom fields to any GLPI itemtype. It is not exposed through the GLPI v2 REST contract so this mixin talks to the legacy v1 -REST API through :class:`~glpi_python_client.auth._v1_session.GLPIV1Session`. +REST API through :class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`. Two abstraction layers are provided: @@ -33,8 +33,8 @@ import json from typing import Any +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError -from glpi_python_client.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.plugins import ( GetPluginFieldsContainer, GetPluginFieldsField, @@ -114,10 +114,10 @@ def _extract_row_id(payload: object) -> int: class PluginFieldsMixin(TransportMixin): - """Synchronous helpers for the GLPI ``Fields`` plugin v1 endpoints. + """Helpers for the GLPI ``Fields`` plugin v1 endpoints. Every method requires the v1 session to be configured on the client - (see :class:`~glpi_python_client.clients.sync_client.GlpiClient`'s + (see :class:`~glpi_python_client._async.clients.sync_client.GlpiClient`'s ``v1_base_url`` and ``v1_user_token`` constructor arguments). """ @@ -312,7 +312,9 @@ def update_item_plugin_field_row( failure_message=f"Failed to update {endpoint}", ) - def get_ticket_custom_fields(self, ticket_id: int) -> dict[str, dict[str, Any]]: + def get_ticket_custom_fields( + self, ticket_id: int + ) -> dict[str, dict[str, Any]]: """Return the custom-field values defined for one ticket. The result is a nested mapping shaped as @@ -338,7 +340,9 @@ def get_ticket_custom_fields(self, ticket_id: int) -> dict[str, dict[str, Any]]: name = container.name if not name: continue - rows = self.list_item_plugin_field_rows(_TICKET_ITEMTYPE, ticket_id, name) + rows = self.list_item_plugin_field_rows( + _TICKET_ITEMTYPE, ticket_id, name + ) if not rows: continue result[name] = dict(rows[0].extra_payload) diff --git a/glpi_python_client/clients/sync_client.py b/glpi_python_client/_sync/clients/client.py similarity index 63% rename from glpi_python_client/clients/sync_client.py rename to glpi_python_client/_sync/clients/client.py index 814d051..8260ea1 100644 --- a/glpi_python_client/clients/sync_client.py +++ b/glpi_python_client/_sync/clients/client.py @@ -1,16 +1,14 @@ -"""Public synchronous GLPI client class. - -The :class:`GlpiClient` class composes the per-endpoint mixins from -:mod:`glpi_python_client.clients.api` with the custom helpers from -:mod:`glpi_python_client.clients.custom` and the synchronous transport -mixin from :mod:`glpi_python_client.clients.commons` to expose the full -public client surface. - -The asynchronous counterpart -:class:`~glpi_python_client.clients.async_client.AsyncGlpiClient` wraps -this very same set of mixins through -:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` so -both surfaces stay in lock-step automatically. +"""Public GLPI client class. + +Composes the per-endpoint mixins from +:mod:`glpi_python_client._async.clients.api` with the aggregated helpers +from :mod:`glpi_python_client._async.clients.custom` and the transport +mixin from :mod:`glpi_python_client._async.clients.commons` to expose the +full public client surface. + +This module is written once. Its counterpart on the other surface is +generated from it, so the two client classes cannot drift apart: there is +no second definition to keep in step. """ from __future__ import annotations @@ -24,8 +22,8 @@ else: # pragma: no cover - fallback for Python 3.10 from typing_extensions import Self -from glpi_python_client.clients._base_client import _BaseGlpiClient -from glpi_python_client.clients.api import ( +from glpi_python_client._sync.clients._base_client import _BaseGlpiClient +from glpi_python_client._sync.clients.api import ( DocumentMixin, EntityMixin, FollowupMixin, @@ -42,8 +40,8 @@ TimelineDocumentMixin, UserMixin, ) -from glpi_python_client.clients.commons._transport import TransportMixin -from glpi_python_client.clients.custom import ( +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.custom import ( StatisticsMixin, TicketContextMixin, ) @@ -72,17 +70,17 @@ class GlpiClient( _BaseGlpiClient, TransportMixin, ): - """Synchronous GLPI client backed by the contract-aligned API mixins. + """GLPI client backed by the contract-aligned API mixins. - The client owns the shared HTTP session, OAuth token manager, and - optional legacy v1 session used solely for binary document uploads. - Token acquisition is serialised by a :class:`threading.Lock` so the - same instance can be safely shared across threads as well as across - asyncio tasks dispatched through - :class:`~glpi_python_client.clients.async_client.AsyncGlpiClient`. + The client owns the shared HTTP session, the OAuth token manager, and + the optional legacy v1 session used for binary document uploads and + the Fields plugin endpoints. Token acquisition is serialised by the + lock from :mod:`glpi_python_client._async._concurrency`, which is the + right primitive for this surface -- see that module for why the two + surfaces cannot share one. Construction parameters and :meth:`from_env` are documented on - :class:`~glpi_python_client.clients._base_client._BaseGlpiClient`. + :class:`~glpi_python_client._async.clients._base_client._BaseGlpiClient`. """ def close(self) -> None: @@ -107,7 +105,7 @@ def __enter__(self) -> Self: Returns ------- - GlpiClient + AsyncGlpiClient The client itself, suitable for chaining method calls. """ @@ -119,7 +117,7 @@ def __exit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: - """Close the client on ``with`` exit. + """Close the client on ``with`` block exit. Parameters ---------- diff --git a/glpi_python_client/_sync/clients/commons/__init__.py b/glpi_python_client/_sync/clients/commons/__init__.py new file mode 100644 index 0000000..149375a --- /dev/null +++ b/glpi_python_client/_sync/clients/commons/__init__.py @@ -0,0 +1,9 @@ +"""Reusable client-layer building blocks shared across the API mixins. + +The commons package centralises constants, HTTP helpers, RSQL filter +builders, transport, and the client configuration helpers +used by the per-endpoint mixins under :mod:`glpi_python_client._async.clients.api` +and the higher-level helpers under :mod:`glpi_python_client._async.clients.custom`. +""" + +from __future__ import annotations diff --git a/glpi_python_client/clients/commons/_config.py b/glpi_python_client/_sync/clients/commons/_config.py similarity index 87% rename from glpi_python_client/clients/commons/_config.py rename to glpi_python_client/_sync/clients/commons/_config.py index 851a083..553f058 100644 --- a/glpi_python_client/clients/commons/_config.py +++ b/glpi_python_client/_sync/clients/commons/_config.py @@ -1,9 +1,8 @@ -"""Configuration and resource setup for the asynchronous GLPI client. +"""Configuration and resource setup for the GLPI client. -The helpers here own environment parsing, URL normalisation, SSL warning -behaviour, and the construction of the runtime resources used by -:class:`glpi_python_client.clients.sync_client.GlpiClient` and -:class:`glpi_python_client.clients.async_client.AsyncGlpiClient`. +The helpers here own environment parsing, URL normalisation, and the +construction of the runtime resources the client owns: the shared HTTP +session, the OAuth token manager, and the optional legacy v1 session. """ from __future__ import annotations @@ -17,8 +16,8 @@ from glpi_python_client._errors import GlpiValidationError if TYPE_CHECKING: - from glpi_python_client.auth._v1_session import GLPIV1Session - from glpi_python_client.auth.auth import GLPITokenManager + from glpi_python_client._sync.auth._v1_session import GLPIV1Session + from glpi_python_client._sync.auth.auth import GLPITokenManager #: Request timeout applied to every call that does not override it. #: @@ -86,7 +85,7 @@ def build_http_session(*, verify_ssl: bool) -> httpx.Client: Returns ------- - httpx.Client + httpx.AsyncClient A client configured for the requested SSL policy. """ @@ -127,8 +126,11 @@ def build_client_resources( module globals. ``None`` uses the default factory. """ - from glpi_python_client.auth._v1_session import GLPIV1Session - from glpi_python_client.auth.auth import GLPITokenManager + from glpi_python_client._sync.auth._v1_session import GLPIV1Session + from glpi_python_client._sync.auth.auth import ( + GLPITokenManager, + validate_credentials, + ) normalized_api_url = normalize_client_api_url( glpi_api_url, @@ -139,21 +141,29 @@ def build_client_resources( v1_user_token=v1_user_token, ) + # Validated before anything is constructed, so a bad configuration never + # leaves a session to unwind. The previous shape -- build the session, + # then close it in an ``except`` clause -- cannot work on the async + # surface: an ``httpx.AsyncClient`` has no synchronous close, and this + # runs from ``__init__``, which cannot await one. + validate_credentials( + client_id=client_id, + client_secret=client_secret, + username=username, + password=password, + ) + factory = session_factory or build_http_session session = factory(verify_ssl=verify_ssl) - try: - auth = GLPITokenManager( - token_url=f"{normalized_api_url}/token", - client_id=client_id, - client_secret=client_secret, - username=username, - password=password, - session=session, - auth_token_refresh=auth_token_refresh, - ) - except Exception: - session.close() - raise + auth = GLPITokenManager( + token_url=f"{normalized_api_url}/token", + client_id=client_id, + client_secret=client_secret, + username=username, + password=password, + session=session, + auth_token_refresh=auth_token_refresh, + ) v1: GLPIV1Session | None = None if v1_base_url and v1_user_token: diff --git a/glpi_python_client/_sync/clients/commons/_constants.py b/glpi_python_client/_sync/clients/commons/_constants.py new file mode 100644 index 0000000..a066759 --- /dev/null +++ b/glpi_python_client/_sync/clients/commons/_constants.py @@ -0,0 +1,60 @@ +"""GLPI v2 endpoint paths and shared transport-layer type aliases. + +The constants here mirror the resource paths defined in the GLPI v2 API +contract under ``docs/glpi_api_contract.json``. Endpoint paths are kept in +one place so the API mixins all use the same resource locations and the +shared HTTP helpers can rely on stable parameter types. +""" + +from __future__ import annotations + +from typing import TypeAlias + +GlpiId: TypeAlias = int +RequestParamValue: TypeAlias = str | int | float | bytes | None + +# administration/ +USER_ENDPOINT = "Administration/User" +ENTITY_ENDPOINT = "Administration/Entity" + +# dropdowns/ +LOCATION_ENDPOINT = "Dropdowns/Location" + +# management/ +DOCUMENT_ENDPOINT = "Management/Document" + +# assistance/ +TICKET_ENDPOINT = "Assistance/Ticket" +TEAM_MEMBER_SUFFIX = "TeamMember" + +# assistance/timeline/ +FOLLOWUP_SUFFIX = "Timeline/Followup" +TASK_SUFFIX = "Timeline/Task" +SOLUTION_SUFFIX = "Timeline/Solution" +TIMELINE_DOCUMENT_SUFFIX = "Timeline/Document" + +# knowledgebase/ +KB_ARTICLE_ENDPOINT = "Knowledgebase/Article" +KB_CATEGORY_ENDPOINT = "Knowledgebase/Category" +KB_COMMENT_SUFFIX = "Comment" +KB_REVISION_SUFFIX = "Revision" + + +__all__ = [ + "DOCUMENT_ENDPOINT", + "ENTITY_ENDPOINT", + "FOLLOWUP_SUFFIX", + "KB_ARTICLE_ENDPOINT", + "KB_CATEGORY_ENDPOINT", + "KB_COMMENT_SUFFIX", + "KB_REVISION_SUFFIX", + "LOCATION_ENDPOINT", + "SOLUTION_SUFFIX", + "TASK_SUFFIX", + "TEAM_MEMBER_SUFFIX", + "TICKET_ENDPOINT", + "TIMELINE_DOCUMENT_SUFFIX", + "USER_ENDPOINT", + "GlpiId", + "RequestParamValue", +] diff --git a/glpi_python_client/_sync/clients/commons/_filters.py b/glpi_python_client/_sync/clients/commons/_filters.py new file mode 100644 index 0000000..12f9b49 --- /dev/null +++ b/glpi_python_client/_sync/clients/commons/_filters.py @@ -0,0 +1,105 @@ +"""RSQL filter helpers for GLPI v2 search endpoints. + +The high-level client uses these helpers to build safe text-search filters +for GLPI endpoints that accept RSQL-like query expressions. All functions +return ``None`` when the supplied input is empty so callers can compose +filters without sprinkling conditional blocks at every call site. +""" + +from __future__ import annotations + + +def rsql_contains_filter(field: str, value: str) -> str | None: + """Build a contains-style RSQL filter for one text field. + + Blank input returns ``None`` so callers can skip adding the filter, while + non-empty input is escaped before being wrapped in wildcard syntax. + """ + + text = value.strip() + if not text: + return None + return f'{field}=like="*{escape_rsql_like_value(text)}*"' + + +def rsql_equals_filter(field: str, value: str | int | None) -> str | None: + """Build an equality-style RSQL filter for one field. + + ``None`` and blank textual values return ``None`` so callers can compose + filters without special-casing absent inputs. + """ + + if value is None: + return None + if isinstance(value, int): + return f"{field}=={value}" + text = value.strip() + if not text: + return None + return f'{field}=="{escape_rsql_text_value(text)}"' + + +def rsql_any_filter(*filters: str | None) -> str | None: + """Join non-empty RSQL filter fragments with OR semantics. + + Empty fragments are ignored and an all-empty input returns ``None``. + + The joined result is wrapped in parentheses whenever it contains more + than one fragment. RSQL binds ``;`` (AND) tighter than ``,`` (OR), so + an unparenthesised group silently loses every preceding AND clause for + all but its first alternative: ``date;e==1,e==2`` parses as + ``(date AND e==1) OR e==2``, which matches every ``e==2`` ticket + regardless of the date window. Measured against a live GLPI 11 + instance, the unparenthesised form returned 16,245 tickets where the + parenthesised form correctly returned 1,552. + """ + + parts = [fragment for fragment in filters if fragment] + if not parts: + return None + if len(parts) == 1: + return parts[0] + return "(" + ",".join(parts) + ")" + + +def rsql_all_filter(*filters: str | None) -> str | None: + """Join non-empty RSQL filter fragments with AND semantics. + + Empty fragments are ignored and an all-empty input returns ``None``. + """ + + parts = [fragment for fragment in filters if fragment] + if not parts: + return None + return ";".join(parts) + + +def escape_rsql_like_value(value: str) -> str: + """Escape user text embedded in a quoted RSQL ``like`` value. + + The helper protects backslashes, quotes, and wildcard characters so + caller input is treated as text instead of modifying the filter + expression itself. + """ + + return value.replace("\\", "\\\\").replace('"', '\\"').replace("*", "\\*") + + +def escape_rsql_text_value(value: str) -> str: + """Escape user text embedded in a quoted RSQL equality value. + + The helper protects backslashes and double quotes so caller input remains + a literal value inside the generated RSQL expression. + """ + + return value.replace("\\", "\\\\").replace('"', '\\"') + + +__all__ = [ + "escape_rsql_like_value", + "escape_rsql_text_value", + "rsql_all_filter", + "rsql_any_filter", + "rsql_contains_filter", + "rsql_equals_filter", +] diff --git a/glpi_python_client/clients/commons/_http.py b/glpi_python_client/_sync/clients/commons/_http.py similarity index 99% rename from glpi_python_client/clients/commons/_http.py rename to glpi_python_client/_sync/clients/commons/_http.py index 9adde2c..550922e 100644 --- a/glpi_python_client/clients/commons/_http.py +++ b/glpi_python_client/_sync/clients/commons/_http.py @@ -12,6 +12,7 @@ import httpx +from glpi_python_client._sync.clients.commons._constants import RequestParamValue from glpi_python_client._errors import ( GlpiProtocolError, GlpiServerError, @@ -19,7 +20,6 @@ GlpiTransportError, status_error_class, ) -from glpi_python_client.clients.commons._constants import RequestParamValue def transport_error_from( diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py new file mode 100644 index 0000000..15e24b3 --- /dev/null +++ b/glpi_python_client/_sync/clients/commons/_payloads.py @@ -0,0 +1,42 @@ +"""Pydantic helpers shared by the per-endpoint mixins. + +The helpers convert :class:`glpi_python_client.models._base.GlpiModel` +instances into the JSON request bodies expected by the GLPI API and back +again, while honouring the per-model ``extra_payload`` escape hatch. +""" + +from __future__ import annotations + +from typing import TypeVar + +from glpi_python_client.models._base import GlpiModel + +ModelT = TypeVar("ModelT", bound=GlpiModel) + + +def model_to_payload(model: GlpiModel) -> dict[str, object]: + """Serialise one :class:`GlpiModel` into a request body. + + ``None`` fields are omitted, the meta ``extra_payload`` field is + excluded from the dump, and any user-provided ``extra_payload`` keys are + merged on top so callers can inject contract-validated extras the + package does not yet model. + """ + + body = model.model_dump(exclude_none=True, exclude={"extra_payload"}) + if model.extra_payload: + body.update(model.extra_payload) + return body + + +def model_from_payload(model_class: type[ModelT], payload: object) -> ModelT: + """Validate one raw GLPI payload into the requested ``GlpiModel`` class. + + The helper is a thin wrapper around ``model_validate`` that keeps the + mixin call sites concise and consistent with :func:`model_to_payload`. + """ + + return model_class.model_validate(payload) + + +__all__ = ["model_from_payload", "model_to_payload"] diff --git a/glpi_python_client/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py similarity index 91% rename from glpi_python_client/clients/commons/_transport.py rename to glpi_python_client/_sync/clients/commons/_transport.py index ae1d9db..d9d1866 100644 --- a/glpi_python_client/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -1,45 +1,38 @@ -"""Synchronous GLPI v2 transport mixin. +"""GLPI v2 transport mixin. The transport mixin owns token handling, header construction, retries, and HTTP request dispatch so the per-endpoint mixins under -:mod:`glpi_python_client.clients.api` can stay focused on resource-specific +:mod:`glpi_python_client._async.clients.api` can stay focused on resource-specific behaviour. Concurrency model ----------------- -The transport is intentionally synchronous and backed by the blocking -``httpx.Client``. Access to the auth token manager is serialised with -a :class:`threading.Lock` rather than an :class:`asyncio.Lock` because: - -* the sync :class:`~glpi_python_client.clients.GlpiClient` can be shared - across user threads, and -* the async :class:`~glpi_python_client.clients.AsyncGlpiClient` runs every - public call on a worker thread through - :func:`asyncio.to_thread`, so concurrent ``asyncio.gather`` fan-outs - contend across OS threads — which an :class:`asyncio.Lock` cannot - protect. - -A single :class:`threading.Lock` covers both clients with one primitive. +Access to the auth token manager is serialised with the lock from +:mod:`glpi_python_client._async._concurrency`. That module is one of only +two maintained separately for each surface, because the correct primitive +genuinely differs: an :class:`asyncio.Lock` for concurrent tasks on one +event loop, a :class:`threading.Lock` for a client shared across threads. +Neither substitutes for the other -- see that module for what breaks in +each direction. + The lock is held only for the short critical section that refreshes the -token; HTTP calls themselves run without the lock so concurrent requests -can proceed in parallel while sharing the same access token. The -underlying :class:`httpx.Client` connection pool is thread-safe for -concurrent HTTP calls; the client is built once at construction time -and is never mutated afterwards. +token. HTTP calls run outside it, so concurrent callers proceed in +parallel while sharing one access token. The underlying HTTP client is +safe for that concurrent use; it is built once at construction and never +mutated afterwards. """ from __future__ import annotations import logging -import threading from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeVar import httpx from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed -from glpi_python_client._errors import GlpiServerError, GlpiTransportError -from glpi_python_client.clients.commons._http import ( +from glpi_python_client._sync._concurrency import Lock +from glpi_python_client._sync.clients.commons._http import ( build_request_headers, build_request_url, ensure_response_status, @@ -51,15 +44,16 @@ transport_error_from, unwrap_timeline_items, ) -from glpi_python_client.clients.commons._payloads import ( +from glpi_python_client._sync.clients.commons._payloads import ( model_from_payload, model_to_payload, ) +from glpi_python_client._errors import GlpiServerError, GlpiTransportError from glpi_python_client.models._base import GlpiModel if TYPE_CHECKING: - from glpi_python_client.auth._v1_session import GLPIV1Session - from glpi_python_client.auth.auth import GLPITokenManager + from glpi_python_client._sync.auth._v1_session import GLPIV1Session + from glpi_python_client._sync.auth.auth import GLPITokenManager logger = logging.getLogger(__name__) @@ -74,7 +68,7 @@ #: the different libraries are completely disjoint — and retries then vanish #: with no error, no warning and a green test suite. Naming #: :class:`~glpi_python_client.GlpiTransportError`, which -#: :func:`~glpi_python_client.clients.commons._http.transport_error_from` +#: :func:`~glpi_python_client._async.clients.commons._http.transport_error_from` #: guarantees every network fault is translated into, makes that failure #: impossible to reintroduce. _RETRY_ON_NETWORK_ERRORS = retry( @@ -86,7 +80,7 @@ class TransportMixin: - """Synchronous GLPI API transport helpers shared by the API mixins. + """GLPI API transport helpers shared by the API mixins. The class declares the runtime attributes the concrete client owns and exposes the blocking ``_get_request``, ``_post_request``, @@ -100,12 +94,12 @@ class TransportMixin: the sync client directly or by the async client through :func:`asyncio.to_thread` — never race while updating shared authentication state. HTTP dispatch runs outside the lock and relies - on the thread-safety of :class:`httpx.Client` for concurrent + on the thread-safety of :class:`httpx.AsyncClient` for concurrent calls. """ _auth: GLPITokenManager - _auth_lock: threading.Lock + _auth_lock: Lock _closed: bool = False _session: httpx.Client _v1: GLPIV1Session | None @@ -376,7 +370,9 @@ def _resource_list( Validated records returned by the GLPI server. """ - response = self._get_request(endpoint, params=params, skip_entity=skip_entity) + response = self._get_request( + endpoint, params=params, skip_entity=skip_entity + ) if failure_message is not None: ensure_response_status( response, @@ -563,7 +559,9 @@ def _resource_delete( request_body = body if request_body is None and delete_model_cls is not None and force is not None: request_body = model_to_payload(delete_model_cls(force=force)) # type: ignore[call-arg] - response = self._delete_request(endpoint, request_body, skip_entity=skip_entity) + response = self._delete_request( + endpoint, request_body, skip_entity=skip_entity + ) ensure_response_status( response, success_statuses=(200, 204), diff --git a/glpi_python_client/_sync/clients/custom/__init__.py b/glpi_python_client/_sync/clients/custom/__init__.py new file mode 100644 index 0000000..3b486eb --- /dev/null +++ b/glpi_python_client/_sync/clients/custom/__init__.py @@ -0,0 +1,23 @@ +"""Higher-level helpers built on top of the API mixins. + +The custom package exposes operations the GLPI API contract does not +advertise directly but which client applications need: the aggregated +ticket-context view and the reporting helpers, both assembled from the +contract-aligned CRUD helpers in +:mod:`glpi_python_client._async.clients.api`. + +Each helper is written once. The fan-out points call ``gather`` from +:mod:`glpi_python_client._async._concurrency`, which runs them +concurrently here and sequentially in the generated tree -- so there is +no second copy of this logic to keep in step. +""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.custom._statistics import StatisticsMixin +from glpi_python_client._sync.clients.custom._ticket_context import TicketContextMixin + +__all__ = [ + "StatisticsMixin", + "TicketContextMixin", +] diff --git a/glpi_python_client/clients/custom/_statistics.py b/glpi_python_client/_sync/clients/custom/_statistics.py similarity index 99% rename from glpi_python_client/clients/custom/_statistics.py rename to glpi_python_client/_sync/clients/custom/_statistics.py index 53e6179..5e1a4b1 100644 --- a/glpi_python_client/clients/custom/_statistics.py +++ b/glpi_python_client/_sync/clients/custom/_statistics.py @@ -2,7 +2,7 @@ The mixin exposes simple aggregations over ticket and ticket-task results returned by the contract-aligned helpers in -:mod:`glpi_python_client.clients.api`. These operations are intentionally +:mod:`glpi_python_client._async.clients.api`. These operations are intentionally kept small and do not perform name resolution or rich label formatting; the caller can correlate the returned numeric identifiers with the dedicated ``search_*`` helpers when required. @@ -14,13 +14,13 @@ from datetime import date, timedelta from typing import TypedDict -from glpi_python_client._errors import GlpiValidationError -from glpi_python_client.clients.commons._filters import ( +from glpi_python_client._sync.clients.commons._filters import ( rsql_all_filter, rsql_any_filter, rsql_contains_filter, ) -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema._common import ( IdNameCompletenameRef, IdNameRef, @@ -142,7 +142,7 @@ class UserActivityResult(TypedDict): class StatisticsMixin(TransportMixin): - """Synchronous custom statistics built on the contract API mixins.""" + """Custom statistics built on the contract API mixins.""" def _v1_ticket_ids_for_actor( self, user_id: int, *, search_options: tuple[int, ...], parameter: str diff --git a/glpi_python_client/clients/custom/_ticket_context.py b/glpi_python_client/_sync/clients/custom/_ticket_context.py similarity index 54% rename from glpi_python_client/clients/custom/_ticket_context.py rename to glpi_python_client/_sync/clients/custom/_ticket_context.py index 7bcd32d..8e68bd7 100644 --- a/glpi_python_client/clients/custom/_ticket_context.py +++ b/glpi_python_client/_sync/clients/custom/_ticket_context.py @@ -7,19 +7,20 @@ from __future__ import annotations -from glpi_python_client.clients.commons._constants import GlpiId -from glpi_python_client.clients.commons._transport import TransportMixin +from glpi_python_client._sync._concurrency import gather +from glpi_python_client._sync.clients.commons._constants import GlpiId +from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext class TicketContextMixin(TransportMixin): - """Synchronous ticket-context aggregation helper. + """Ticket-context aggregation helper. The mixin assumes the consuming client also exposes the ticket and - ticket-timeline helpers from :mod:`glpi_python_client.clients.api`. + ticket-timeline helpers from :mod:`glpi_python_client._async.clients.api`. The five underlying calls are executed sequentially; the async variant under - :mod:`glpi_python_client.clients.custom._ticket_context_async` + :mod:`glpi_python_client._async.clients.custom._ticket_context_async` overrides :meth:`get_ticket_context` to fan them out concurrently. """ @@ -27,7 +28,8 @@ def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext: """Return one aggregated ticket context view. The primary ticket fetch and the four timeline list calls are - executed sequentially in this synchronous implementation. + issued through ``gather``: concurrently on the async surface, + sequentially on the generated sync one. Parameters ---------- @@ -48,12 +50,17 @@ def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext: HTTP status. """ - ticket = self.get_ticket(ticket_id) # type: ignore[attr-defined] - tasks = self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined] - followups = self.list_ticket_followups(ticket_id) # type: ignore[attr-defined] - solutions = self.list_ticket_solutions(ticket_id) # type: ignore[attr-defined] - documents = self.list_ticket_timeline_documents( # type: ignore[attr-defined] - ticket_id + # The five reads are independent, so they go through ``gather``: + # concurrently on the async surface, and one after the other on the + # generated sync one, where each argument has already been evaluated + # by the time ``gather`` is entered. One expression, both meanings -- + # which is what lets this method exist exactly once. + ticket, tasks, followups, solutions, documents = gather( + self.get_ticket(ticket_id), # type: ignore[attr-defined] + self.list_ticket_tasks(ticket_id), # type: ignore[attr-defined] + self.list_ticket_followups(ticket_id), # type: ignore[attr-defined] + self.list_ticket_solutions(ticket_id), # type: ignore[attr-defined] + self.list_ticket_timeline_documents(ticket_id), # type: ignore[attr-defined] ) return GlpiTicketContext( ticket=ticket, diff --git a/glpi_python_client/clients/__init__.py b/glpi_python_client/clients/__init__.py deleted file mode 100644 index b0ddab8..0000000 --- a/glpi_python_client/clients/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Public client exports for the GLPI Python package. - -The package exposes two client classes: - -* :class:`GlpiClient` — synchronous, blocking client. The single source - of truth for endpoint behaviour. -* :class:`AsyncGlpiClient` — asynchronous facade that wraps every - synchronous method into a coroutine via - :class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`. - -Both classes share the same endpoint surface; pick the one matching -your runtime model. -""" - -from __future__ import annotations - -from glpi_python_client.clients.async_client import AsyncGlpiClient -from glpi_python_client.clients.sync_client import GlpiClient - -__all__ = ["AsyncGlpiClient", "GlpiClient"] diff --git a/glpi_python_client/clients/api/administration/__init__.py b/glpi_python_client/clients/api/administration/__init__.py deleted file mode 100644 index c7b65dc..0000000 --- a/glpi_python_client/clients/api/administration/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""GLPI ``/Administration`` mixins for the Synchronous client. - -The submodules expose the user and entity mixins used by -:class:`glpi_python_client.clients.sync_client.GlpiClient` and -:class:`glpi_python_client.clients.async_client.AsyncGlpiClient`. -""" - -from __future__ import annotations - -from glpi_python_client.clients.api.administration._entity import EntityMixin -from glpi_python_client.clients.api.administration._user import UserMixin - -__all__ = ["EntityMixin", "UserMixin"] diff --git a/glpi_python_client/clients/api/assistance/__init__.py b/glpi_python_client/clients/api/assistance/__init__.py deleted file mode 100644 index 3bdcbeb..0000000 --- a/glpi_python_client/clients/api/assistance/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""GLPI ``/Assistance`` mixins for the Synchronous client.""" - -from __future__ import annotations - -from glpi_python_client.clients.api.assistance._team import TeamMemberMixin -from glpi_python_client.clients.api.assistance._ticket import TicketMixin - -__all__ = ["TeamMemberMixin", "TicketMixin"] diff --git a/glpi_python_client/clients/api/assistance/timeline/__init__.py b/glpi_python_client/clients/api/assistance/timeline/__init__.py deleted file mode 100644 index 1685427..0000000 --- a/glpi_python_client/clients/api/assistance/timeline/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""GLPI ticket-timeline mixins for the Synchronous client.""" - -from __future__ import annotations - -from glpi_python_client.clients.api.assistance.timeline._document import ( - TimelineDocumentMixin, -) -from glpi_python_client.clients.api.assistance.timeline._followup import ( - FollowupMixin, -) -from glpi_python_client.clients.api.assistance.timeline._solution import ( - SolutionMixin, -) -from glpi_python_client.clients.api.assistance.timeline._task import ( - TicketTaskMixin, -) - -__all__ = [ - "FollowupMixin", - "SolutionMixin", - "TicketTaskMixin", - "TimelineDocumentMixin", -] diff --git a/glpi_python_client/clients/api/dropdowns/__init__.py b/glpi_python_client/clients/api/dropdowns/__init__.py deleted file mode 100644 index 6edc745..0000000 --- a/glpi_python_client/clients/api/dropdowns/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""GLPI ``/Dropdowns`` mixins for the Synchronous client.""" - -from __future__ import annotations - -from glpi_python_client.clients.api.dropdowns._location import LocationMixin - -__all__ = ["LocationMixin"] diff --git a/glpi_python_client/clients/api/knowledgebase/__init__.py b/glpi_python_client/clients/api/knowledgebase/__init__.py deleted file mode 100644 index 89ee53a..0000000 --- a/glpi_python_client/clients/api/knowledgebase/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""GLPI ``/Knowledgebase`` mixins for the synchronous and asynchronous clients.""" - -from __future__ import annotations - -from glpi_python_client.clients.api.knowledgebase._article import KBArticleMixin -from glpi_python_client.clients.api.knowledgebase._article_async import ( - AsyncKBArticleMixin, -) -from glpi_python_client.clients.api.knowledgebase._category import KBCategoryMixin -from glpi_python_client.clients.api.knowledgebase._comment import ( - KBArticleCommentMixin, -) -from glpi_python_client.clients.api.knowledgebase._revision import ( - KBArticleRevisionMixin, -) - -__all__ = [ - "AsyncKBArticleMixin", - "KBArticleCommentMixin", - "KBArticleMixin", - "KBArticleRevisionMixin", - "KBCategoryMixin", -] diff --git a/glpi_python_client/clients/api/knowledgebase/_article_async.py b/glpi_python_client/clients/api/knowledgebase/_article_async.py deleted file mode 100644 index 74dd642..0000000 --- a/glpi_python_client/clients/api/knowledgebase/_article_async.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Asynchronous overrides for KB article category assignment. - -The v2 API exposes ``KBArticle.categories[].id`` as ``readOnly``, so -:meth:`create_kb_article` and :meth:`update_kb_article` apply categories -through the legacy v1 ``_categories`` fallback. That fallback calls the -public :meth:`set_kb_article_categories` through ``self``, which the async -bridge has wrapped into a coroutine — so the synchronous bodies drop the -call and the article silently keeps no categories. - -These overrides strip ``categories`` from the model, run the untouched -synchronous v2 write in a worker thread (its own fallback then no-ops), -and apply the categories with an awaited call. Keeping the sync module -untouched makes the fix purely additive. - -The mixin must sit **before** :class:`KBArticleMixin` in the -:class:`~glpi_python_client.clients.AsyncGlpiClient` base list. -""" - -from __future__ import annotations - -import asyncio - -from glpi_python_client._errors import GlpiValidationError -from glpi_python_client.clients.api.knowledgebase._article import KBArticleMixin -from glpi_python_client.clients.commons._constants import GlpiId -from glpi_python_client.models.api_schema._common import IdNameRef -from glpi_python_client.models.api_schema.knowledgebase._article import ( - PatchKBArticle, - PostKBArticle, -) - - -class AsyncKBArticleMixin(KBArticleMixin): - """Async overrides for the two KB article writes that set categories.""" - - async def _apply_category_fallback_async( - self, article_id: GlpiId, categories: list[IdNameRef] | None - ) -> None: - """Apply ``categories`` through the awaited legacy fallback. - - Mirrors :meth:`KBArticleMixin._apply_category_fallback` but awaits - the bridge-wrapped :meth:`set_kb_article_categories`. - - Parameters - ---------- - article_id : GlpiId - Identifier of the article to re-categorise. - categories : list[IdNameRef] | None - Category references to link. ``None`` is a no-op; an empty - list clears every category. - - Returns - ------- - None - - Raises - ------ - GlpiValidationError - When a category reference lacks an ``id``. - """ - - if categories is None: - return - ids: list[int] = [] - for ref in categories: - if ref.id is None: - raise GlpiValidationError( - "KB article categories require an 'id' to be linked; got a " - "category reference without an id." - ) - ids.append(ref.id) - # ``set_kb_article_categories`` is declared on ``KBArticleMixin``, the - # very class this mixin subclasses, so mypy resolves it statically as - # the synchronous ``-> None`` method rather than the bridge-generated - # coroutine it becomes at runtime on ``AsyncGlpiClient``. That mismatch - # is exactly what makes the await necessary here. - await self.set_kb_article_categories( # type: ignore[misc, func-returns-value] - article_id, ids - ) - - async def create_kb_article( # type: ignore[override] - self, article: PostKBArticle - ) -> int: - """Create one knowledge base article and return its new identifier. - - Async override of :meth:`KBArticleMixin.create_kb_article`. The v2 - create runs in a worker thread with ``categories`` stripped, then - the categories are applied through the awaited legacy fallback. - Error semantics match the synchronous version: the create is not - undone when the category assignment fails. - - Parameters - ---------- - article : PostKBArticle - Body of the article to create. - - Returns - ------- - int - Identifier assigned by GLPI. - - Raises - ------ - RuntimeError - When the article was created but assigning its categories - failed. The message names the new article id. - """ - - stripped = article.model_copy(update={"categories": None}) - new_id: int = await asyncio.to_thread( - KBArticleMixin.create_kb_article, self, stripped - ) - if article.categories: - try: - await self._apply_category_fallback_async(new_id, article.categories) - except Exception as exc: - raise RuntimeError( - f"KB article {new_id} was created but assigning its " - f"categories failed: {exc}" - ) from exc - return new_id - - async def update_kb_article( # type: ignore[override] - self, article_id: GlpiId, article: PatchKBArticle - ) -> None: - """Update one knowledge base article with a partial body. - - Async override of :meth:`KBArticleMixin.update_kb_article`. The v2 - patch runs in a worker thread with ``categories`` stripped, then - the categories are applied through the awaited legacy fallback. - ``None`` leaves categories untouched; an empty list clears them. - - Parameters - ---------- - article_id : GlpiId - Identifier of the article to update. - article : PatchKBArticle - Partial body to apply. - - Returns - ------- - None - """ - - stripped = article.model_copy(update={"categories": None}) - await asyncio.to_thread( - KBArticleMixin.update_kb_article, self, article_id, stripped - ) - await self._apply_category_fallback_async(article_id, article.categories) - - -__all__ = ["AsyncKBArticleMixin"] diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py b/glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py deleted file mode 100644 index 86d7f24..0000000 --- a/glpi_python_client/clients/api/knowledgebase/tests/test_article_async.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Async-client tests for KB article category assignment. - -The v2 API cannot write KB categories, so create/update apply them through -the legacy v1 ``_categories`` fallback. That fallback runs through the -public ``set_kb_article_categories``, which the async bridge wraps into a -coroutine — so without an override the category write is silently dropped -and the article is created with no category at all. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from glpi_python_client import ( - AsyncGlpiClient, - GlpiValidationError, - PatchKBArticle, - PostKBArticle, -) -from glpi_python_client.models.api_schema._common import IdNameRef -from glpi_python_client.testing.utils import FakeResponse, make_async_client - - -class _FakeV1: - """Stand-in for ``GLPIV1Session`` recording ``request_json`` calls.""" - - def __init__(self) -> None: - self.calls: list[dict[str, Any]] = [] - - def request_json( - self, - method: str, - path: str, - *, - params: dict[str, object] | None = None, - json_body: dict[str, object] | None = None, - success_statuses: tuple[int, ...] = (200, 201, 204, 206), - failure_message: str | None = None, - ) -> object: - self.calls.append({"method": method, "path": path, "json_body": json_body}) - return [{"1": True, "message": ""}] - - -@pytest.fixture -def client() -> AsyncGlpiClient: - """Return an async client with the v2 transport stubbed out. - - Both stubs record every ``json_body`` they receive (on the ``post_bodies`` - / ``patch_bodies`` attributes attached to the client) so tests can assert - that stripping ``categories`` for the legacy fallback left the rest of - the v2 request body untouched. - """ - - c = make_async_client() - post_bodies: list[dict[str, object] | None] = [] - patch_bodies: list[dict[str, object] | None] = [] - - def _post( - endpoint: str, - json_body: dict[str, object] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - post_bodies.append(json_body) - return FakeResponse(status_code=201, payload={"id": 42}) - - def _patch( - endpoint: str, json_body: dict[str, object] | None = None - ) -> FakeResponse: - patch_bodies.append(json_body) - return FakeResponse(status_code=200, payload={"id": 42}) - - c._post_request = _post # type: ignore[assignment] - c._update_request = _patch # type: ignore[assignment] - c.post_bodies = post_bodies # type: ignore[attr-defined] - c.patch_bodies = patch_bodies # type: ignore[attr-defined] - return c - - -async def test_create_kb_article_links_categories(client: AsyncGlpiClient) -> None: - """Creating with categories must actually issue the v1 category write.""" - - fake = _FakeV1() - client._v1 = fake # type: ignore[assignment] - - new_id = await client.create_kb_article( - PostKBArticle(name="t", answer="a", categories=[IdNameRef(id=7, name="cat")]) - ) - - assert new_id == 42 - assert fake.calls == [ - { - "method": "PUT", - "path": "KnowbaseItem/42", - "json_body": {"input": {"_categories": [7]}}, - } - ] - # The stripped v2 body must still carry every other field: only - # ``categories`` was removed before the worker-thread create call runs. - # A ``model_copy(update=...)`` that nuked more than ``categories`` would - # otherwise pass every other assertion in this file undetected. - assert client.post_bodies == [{"name": "t", "answer": "a"}] # type: ignore[attr-defined] - - -async def test_create_kb_article_without_categories_needs_no_v1( - client: AsyncGlpiClient, -) -> None: - """Omitting categories must not require a v1 session.""" - - client._v1 = None - assert await client.create_kb_article(PostKBArticle(name="t", answer="a")) == 42 - - -async def test_create_kb_article_wraps_category_failure( - client: AsyncGlpiClient, -) -> None: - """A category failure after create raises RuntimeError naming the id.""" - - client._v1 = None # no v1 session -> the fallback raises RuntimeError - - with pytest.raises(RuntimeError, match="KB article 42 was created but"): - await client.create_kb_article( - PostKBArticle( - name="t", answer="a", categories=[IdNameRef(id=7, name="cat")] - ) - ) - - -async def test_create_kb_article_wraps_missing_id_category( - client: AsyncGlpiClient, -) -> None: - """A category ref without an id is wrapped in the same ``RuntimeError``. - - ``_apply_category_fallback_async`` raises ``ValueError`` before ever - touching a v1 session when a category reference has no ``id`` (see - ``_article_async.py``). ``create_kb_article`` wraps every fallback - failure — including this one — into ``RuntimeError``. This is the - async copy of a branch already covered on the sync client; the two - copies can drift independently, so this branch needs its own test - rather than relying on sync coverage. - """ - - with pytest.raises(RuntimeError, match="KB article 42 was created but"): - await client.create_kb_article( - PostKBArticle(name="t", answer="a", categories=[IdNameRef(name="cat")]) - ) - - -async def test_update_kb_article_links_categories(client: AsyncGlpiClient) -> None: - """Updating with a non-empty list must issue the v1 category write. - - This is the update-path counterpart of - ``test_create_kb_article_links_categories``. Without it, the - non-empty-list case on ``update_kb_article`` is only covered by - composition: the ``[]`` test below proves the update path fires the - legacy fallback at all, and the create test proves the id-collection - loop works, but neither proves a non-empty list on *update*, the - headline regression this branch fixed, actually reaches the v1 - ``PUT``. - """ - - fake = _FakeV1() - client._v1 = fake # type: ignore[assignment] - - await client.update_kb_article( - 42, PatchKBArticle(name="t3", categories=[IdNameRef(id=7, name="cat")]) - ) - - assert fake.calls == [ - { - "method": "PUT", - "path": "KnowbaseItem/42", - "json_body": {"input": {"_categories": [7]}}, - } - ] - # The stripped v2 patch body must still carry every other field: only - # ``categories`` was removed before the worker-thread update call runs. - assert client.patch_bodies == [{"name": "t3"}] # type: ignore[attr-defined] - - -async def test_update_kb_article_clears_categories(client: AsyncGlpiClient) -> None: - """An empty list clears every category through the v1 fallback.""" - - fake = _FakeV1() - client._v1 = fake # type: ignore[assignment] - - await client.update_kb_article(42, PatchKBArticle(name="t2", categories=[])) - - assert fake.calls == [ - { - "method": "PUT", - "path": "KnowbaseItem/42", - "json_body": {"input": {"_categories": []}}, - } - ] - # The stripped v2 patch body must still carry every other field: only - # ``categories`` was removed before the worker-thread update call runs. - # A ``model_copy(update=...)`` that nuked more than ``categories`` would - # otherwise pass every other assertion in this file undetected. - assert client.patch_bodies == [{"name": "t2"}] # type: ignore[attr-defined] - - -async def test_update_kb_article_raises_on_missing_id_category( - client: AsyncGlpiClient, -) -> None: - """A category ref without an id raises the raw ``GlpiValidationError`` on update. - - Unlike ``create_kb_article``, ``update_kb_article`` does not wrap the - fallback call in a ``try``/``except``: the v2 patch has already been - applied by the time categories are assigned, so there is nothing to - roll back and no article-was-created message to build around. The raw - ``GlpiValidationError`` from ``_apply_category_fallback_async`` must - therefore propagate unchanged. ``GlpiValidationError`` inherits - ``ValueError`` so existing callers that catch the broader type keep - working. - """ - - with pytest.raises(GlpiValidationError, match="require an 'id'") as excinfo: - await client.update_kb_article( - 42, PatchKBArticle(name="t2", categories=[IdNameRef(name="cat")]) - ) - assert isinstance(excinfo.value, ValueError) - - -async def test_update_kb_article_without_categories_skips_v1( - client: AsyncGlpiClient, -) -> None: - """``categories=None`` leaves categories untouched and calls no v1.""" - - fake = _FakeV1() - client._v1 = fake # type: ignore[assignment] - - await client.update_kb_article(42, PatchKBArticle(name="t2")) - - assert fake.calls == [] diff --git a/glpi_python_client/clients/api/management/__init__.py b/glpi_python_client/clients/api/management/__init__.py deleted file mode 100644 index fc085b5..0000000 --- a/glpi_python_client/clients/api/management/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""GLPI ``/Management`` mixins for the Synchronous client.""" - -from __future__ import annotations - -from glpi_python_client.clients.api.management._document import DocumentMixin - -__all__ = ["DocumentMixin"] diff --git a/glpi_python_client/clients/api/plugins/__init__.py b/glpi_python_client/clients/api/plugins/__init__.py deleted file mode 100644 index f064e45..0000000 --- a/glpi_python_client/clients/api/plugins/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API. - -Plugins are not advertised in the v2 OpenAPI contract so the mixins -under this package go through the v1 session helper exposed by -:class:`~glpi_python_client.auth._v1_session.GLPIV1Session`. -""" - -from glpi_python_client.clients.api.plugins._fields import PluginFieldsMixin -from glpi_python_client.clients.api.plugins._fields_async import AsyncPluginFieldsMixin - -__all__ = ["AsyncPluginFieldsMixin", "PluginFieldsMixin"] diff --git a/glpi_python_client/clients/api/plugins/_fields_async.py b/glpi_python_client/clients/api/plugins/_fields_async.py deleted file mode 100644 index a84d1f3..0000000 --- a/glpi_python_client/clients/api/plugins/_fields_async.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Asynchronous overrides for the Fields plugin aggregation helpers. - -:meth:`get_ticket_custom_fields` and :meth:`set_ticket_custom_fields` call -sibling public methods through ``self``. Under the async bridge those -resolve to coroutine functions, so the synchronous bodies would receive -coroutine objects instead of data. These overrides await the -bridge-wrapped calls on the event loop instead. - -The mixin must sit **before** :class:`PluginFieldsMixin` in the -:class:`~glpi_python_client.clients.AsyncGlpiClient` base list so the -bridge's ``__init_subclass__`` hook finds the coroutine via ``getattr`` -and leaves it alone. - -Every awaited call below carries ``# type: ignore[misc]``: the awaited -method (e.g. :meth:`PluginFieldsMixin.list_plugin_fields_containers`) is -declared on this mixin's own parent, so mypy resolves it statically as -the synchronous ``-> T`` method rather than the bridge-generated -coroutine it becomes at runtime on -:class:`~glpi_python_client.clients.AsyncGlpiClient`. See the matching -note in -:mod:`glpi_python_client.clients.api.knowledgebase._article_async` for -the same vocabulary, and contrast with the ``[attr-defined]`` codes in -:mod:`glpi_python_client.clients.custom._statistics_async`, where the -awaited methods are declared on a *different* mixin and mypy cannot -resolve them statically at all. -""" - -from __future__ import annotations - -from typing import Any - -from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError -from glpi_python_client.clients.api.plugins._fields import ( - _TICKET_ITEMTYPE, - PluginFieldsMixin, -) -from glpi_python_client.models.api_schema.plugins import GetPluginFieldsContainer - - -class AsyncPluginFieldsMixin(PluginFieldsMixin): - """Async overrides for the two Fields plugin aggregation helpers.""" - - async def get_ticket_custom_fields( # type: ignore[override] - self, ticket_id: int - ) -> dict[str, dict[str, Any]]: - """Return the custom-field values defined for one ticket. - - Async override of - :meth:`PluginFieldsMixin.get_ticket_custom_fields`; the awaited - calls are the bridge-wrapped public helpers. - - Parameters - ---------- - ticket_id : int - Identifier of the ticket whose custom values are requested. - - Returns - ------- - dict[str, dict[str, Any]] - Per-container value mappings. Empty when the ticket has no - stored custom values across any container. - """ - - containers = await self.list_plugin_fields_containers( # type: ignore[misc] - itemtype=_TICKET_ITEMTYPE - ) - result: dict[str, dict[str, Any]] = {} - for container in containers: - name = container.name - if not name: - continue - rows = await self.list_item_plugin_field_rows( # type: ignore[misc] - _TICKET_ITEMTYPE, ticket_id, name - ) - if not rows: - continue - result[name] = dict(rows[0].extra_payload) - return result - - async def set_ticket_custom_fields( # type: ignore[override] - self, - ticket_id: int, - values: dict[str, dict[str, Any]], - ) -> None: - """Persist custom-field values on one ticket. - - Async override of - :meth:`PluginFieldsMixin.set_ticket_custom_fields`. Validation - order is identical to the synchronous version: unknown containers - and fields raise before any write. - - Parameters - ---------- - ticket_id : int - Identifier of the ticket whose custom values must be set. - values : dict[str, dict[str, Any]] - Nested mapping ``{container_name: {field_name: value}}``. - - Returns - ------- - None - """ - - if not values: - return - - containers = await self.list_plugin_fields_containers( # type: ignore[misc] - itemtype=_TICKET_ITEMTYPE - ) - by_name: dict[str, GetPluginFieldsContainer] = { - c.name: c for c in containers if c.name is not None - } - unknown = sorted(set(values) - set(by_name)) - if unknown: - raise GlpiValidationError( - "Unknown plugin-fields container(s) for Ticket: " + ", ".join(unknown) - ) - - for container_name, column_values in values.items(): - container = by_name[container_name] - if container.id is None: - raise GlpiProtocolError( - f"Container {container_name!r} has no id; cannot write values" - ) - - declared_fields = await self.list_plugin_fields_fields( # type: ignore[misc] - container_id=container.id - ) - declared = {f.name for f in declared_fields if f.name is not None} - unknown_fields = sorted(set(column_values) - declared) - if unknown_fields: - raise GlpiValidationError( - f"Unknown field(s) for container {container_name!r}: " - + ", ".join(unknown_fields) - ) - - existing_rows = await self.list_item_plugin_field_rows( # type: ignore[misc] - _TICKET_ITEMTYPE, ticket_id, container_name - ) - if existing_rows and existing_rows[0].id is not None: - await self.update_item_plugin_field_row( # type: ignore[misc, func-returns-value] - itemtype=_TICKET_ITEMTYPE, - container_name=container_name, - row_id=existing_rows[0].id, - values=column_values, - ) - else: - await self.create_item_plugin_field_row( # type: ignore[misc] - itemtype=_TICKET_ITEMTYPE, - items_id=ticket_id, - container_id=container.id, - container_name=container_name, - values=column_values, - ) - - -__all__ = ["AsyncPluginFieldsMixin"] diff --git a/glpi_python_client/clients/api/plugins/tests/test_fields_async.py b/glpi_python_client/clients/api/plugins/tests/test_fields_async.py deleted file mode 100644 index 77fab60..0000000 --- a/glpi_python_client/clients/api/plugins/tests/test_fields_async.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Async-client tests for the Fields plugin aggregation helpers. - -These helpers call sibling *public* methods through ``self``. On -``AsyncGlpiClient`` those resolve to bridge-wrapped coroutines, so without -a hand-written async override they raise ``TypeError: 'coroutine' object -is not iterable``. See clients/tests/test_async_selfcall_guard.py. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from glpi_python_client import AsyncGlpiClient, GlpiProtocolError, GlpiValidationError -from glpi_python_client.testing.utils import make_async_client - - -class _FakeV1: - """Stand-in for ``GLPIV1Session`` returning queued payloads.""" - - def __init__(self, responses: list[object]) -> None: - self.responses = list(responses) - self.calls: list[dict[str, Any]] = [] - - def request_json( - self, - method: str, - path: str, - *, - params: dict[str, object] | None = None, - json_body: dict[str, object] | None = None, - success_statuses: tuple[int, ...] = (200, 201, 204, 206), - failure_message: str | None = None, - ) -> object: - self.calls.append({"method": method, "path": path, "json_body": json_body}) - if not self.responses: - raise AssertionError(f"Unexpected v1 call: {method} {path}") - return self.responses.pop(0) - - -@pytest.fixture -def client() -> AsyncGlpiClient: - """Return an async client with no HTTP plumbing wired up.""" - - return make_async_client() - - -async def test_get_ticket_custom_fields_returns_values( - client: AsyncGlpiClient, -) -> None: - """The async helper must return data, not a dropped coroutine.""" - - client._v1 = _FakeV1( # type: ignore[assignment] - [ - [{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}], - [{"id": 5, "customfield": "hello"}], - ] - ) - - result = await client.get_ticket_custom_fields(1) - - assert result == {"custom": {"customfield": "hello"}} - - -async def test_set_ticket_custom_fields_updates_existing_row( - client: AsyncGlpiClient, -) -> None: - """An existing value row is updated in place through the v1 session.""" - - fake = _FakeV1( - [ - [{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}], - [{"id": 9, "plugin_fields_containers_id": 1, "name": "customfield"}], - [{"id": 5, "customfield": "old"}], - [{"5": True, "message": ""}], - ] - ) - client._v1 = fake # type: ignore[assignment] - - await client.set_ticket_custom_fields(1, {"custom": {"customfield": "new"}}) - - update = fake.calls[-1] - assert update["method"] == "PUT" - assert update["json_body"] == {"input": {"id": 5, "customfield": "new"}} - - -async def test_set_ticket_custom_fields_rejects_unknown_container( - client: AsyncGlpiClient, -) -> None: - """Unknown containers raise before any write. - - ``GlpiValidationError`` inherits ``ValueError`` so existing callers that - catch the broader type keep working. - """ - - client._v1 = _FakeV1( # type: ignore[assignment] - [[{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}]] - ) - - with pytest.raises( - GlpiValidationError, match="Unknown plugin-fields container" - ) as excinfo: - await client.set_ticket_custom_fields(1, {"nope": {"x": 1}}) - assert isinstance(excinfo.value, ValueError) - - -async def test_set_ticket_custom_fields_rejects_container_without_id( - client: AsyncGlpiClient, -) -> None: - """A matched container with no ``id`` raises before any write. - - The container came from the server's own - ``list_plugin_fields_containers`` response, so a missing ``id`` is a - server-side contract violation, not a caller mistake: - ``GlpiProtocolError``. It still inherits ``ValueError`` so existing - callers that catch the broader type keep working. - """ - - client._v1 = _FakeV1( # type: ignore[assignment] - [[{"name": "custom", "itemtypes": '["Ticket"]'}]] - ) - - with pytest.raises(GlpiProtocolError, match="has no id") as excinfo: - await client.set_ticket_custom_fields(1, {"custom": {"customfield": "x"}}) - assert isinstance(excinfo.value, ValueError) - - -async def test_set_ticket_custom_fields_rejects_unknown_field( - client: AsyncGlpiClient, -) -> None: - """A typo in the field name raises before any write. - - ``GlpiValidationError`` inherits ``ValueError`` so existing callers that - catch the broader type keep working. - """ - - client._v1 = _FakeV1( # type: ignore[assignment] - [ - [{"id": 1, "name": "custom", "itemtypes": '["Ticket"]'}], - [{"id": 9, "plugin_fields_containers_id": 1, "name": "customfield"}], - ] - ) - - with pytest.raises(GlpiValidationError, match="Unknown field") as excinfo: - await client.set_ticket_custom_fields(1, {"custom": {"typo": "value"}}) - assert isinstance(excinfo.value, ValueError) diff --git a/glpi_python_client/clients/async_client.py b/glpi_python_client/clients/async_client.py deleted file mode 100644 index e8d4d30..0000000 --- a/glpi_python_client/clients/async_client.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Public asynchronous GLPI client class. - -The :class:`AsyncGlpiClient` reuses every synchronous mixin composed -into :class:`~glpi_python_client.clients.sync_client.GlpiClient` and -wraps each public method into a coroutine through -:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`. -Some methods ship hand-written async overrides instead — for concurrent -``asyncio.gather`` fan-out (:mod:`glpi_python_client.clients.custom`) or -to stop the bridge from silently dropping an internal call to a sibling -public method through ``self`` -(:mod:`glpi_python_client.clients.api.knowledgebase`, -:mod:`glpi_python_client.clients.api.plugins`). - -The async client owns the same HTTP session and token manager as the -synchronous client but its lifecycle is driven through ``async with`` / -``await close()``. Token acquisition is still serialised by the shared -:class:`threading.Lock` so concurrent ``asyncio.gather`` calls cannot -race on the worker threads spawned by :func:`asyncio.to_thread`. -""" - -from __future__ import annotations - -import asyncio -import logging -import sys -from concurrent.futures import Executor -from types import TracebackType -from typing import Any - -if sys.version_info >= (3, 11): - from typing import Self -else: # pragma: no cover - fallback for Python 3.10 - from typing_extensions import Self - -from glpi_python_client.clients._base_client import _BaseGlpiClient -from glpi_python_client.clients.api import ( - AsyncKBArticleMixin, - AsyncPluginFieldsMixin, - DocumentMixin, - EntityMixin, - FollowupMixin, - KBArticleCommentMixin, - KBArticleRevisionMixin, - KBCategoryMixin, - LocationMixin, - SolutionMixin, - TeamMemberMixin, - TicketMixin, - TicketTaskMixin, - TimelineDocumentMixin, - UserMixin, -) -from glpi_python_client.clients.commons._async_bridge import AsyncBridge -from glpi_python_client.clients.commons._transport import TransportMixin -from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin -from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin -from glpi_python_client.clients.custom._ticket_context_async import ( - AsyncTicketContextMixin, -) - -logger = logging.getLogger(__name__) - - -class AsyncGlpiClient( # type: ignore[misc] - AsyncBridge, - AsyncPaginationMixin, - TicketMixin, - TicketTaskMixin, - FollowupMixin, - SolutionMixin, - TimelineDocumentMixin, - TeamMemberMixin, - DocumentMixin, - UserMixin, - EntityMixin, - LocationMixin, - KBCategoryMixin, - AsyncKBArticleMixin, - KBArticleCommentMixin, - KBArticleRevisionMixin, - AsyncPluginFieldsMixin, - AsyncTicketContextMixin, - AsyncStatisticsMixin, - _BaseGlpiClient, - TransportMixin, -): - """Asynchronous GLPI client built on the sync mixins via the bridge. - - Every public sync method exposed by the inherited mixins is - automatically wrapped into a coroutine that defers the blocking call - to a worker thread. A handful of methods ship hand-written async - overrides instead — for concurrent fan-out or to stop the bridge - from silently dropping an internal call to a sibling public method - through ``self`` — which are preserved as coroutine functions by the - bridge. - - Construction parameters and :meth:`from_env` are documented on - :class:`~glpi_python_client.clients._base_client._BaseGlpiClient`; - the only additional keyword is ``executor`` (described below). - """ - - def __init__(self, *, executor: Executor | None = None, **kwargs: Any) -> None: - """Build an asynchronous GLPI client and its transport resources. - - Parameters - ---------- - executor : concurrent.futures.Executor | None, optional - Optional executor that every bridge-generated wrapped call - (see :class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`) - is routed through. When ``None`` (the default) the bridge - falls back to :func:`asyncio.to_thread`, which uses the - loop's default thread pool executor. Supply a dedicated - :class:`concurrent.futures.ThreadPoolExecutor` when the - application performs aggressive fan-outs that would - otherwise saturate the default pool. This does **not** - cover the hand-written - :class:`~glpi_python_client.clients.api.knowledgebase._article_async.AsyncKBArticleMixin` - overrides (``create_kb_article``/``update_kb_article``): they - dispatch their worker-thread call through a plain - :func:`asyncio.to_thread` and always use the loop's default - thread pool regardless of this argument. - **kwargs : Any - Remaining keyword arguments forwarded to - :class:`~glpi_python_client.clients._base_client._BaseGlpiClient`. - - Raises - ------ - GlpiValidationError - If the supplied configuration is incomplete or invalid (e.g. - missing OAuth credentials together with no v1 fallback). - """ - - super().__init__(**kwargs) - self._executor = executor - - async def close(self) -> None: - """Release every resource owned by the client. - - The shared HTTP session is closed off-thread, the optional v1 - fallback session is closed off-thread, and the client is marked - as closed so subsequent calls raise immediately. The method is - idempotent. - """ - - if self._closed: - return - try: - await asyncio.to_thread(self._session.close) - if self._v1 is not None: - await asyncio.to_thread(self._v1.close) - finally: - self._closed = True - - async def __aenter__(self) -> Self: - """Return the client unchanged for use in an ``async with`` block. - - Returns - ------- - AsyncGlpiClient - The client itself, suitable for chaining method calls. - """ - - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - """Close the client on ``async with`` exit. - - Parameters - ---------- - exc_type : type[BaseException] | None - Exception class raised inside the ``async with`` block, if any. - exc : BaseException | None - Exception instance raised inside the block, if any. - tb : TracebackType | None - Traceback associated with ``exc``. - """ - - await self.close() - - -__all__ = ["AsyncGlpiClient"] diff --git a/glpi_python_client/clients/commons/_async_bridge.py b/glpi_python_client/clients/commons/_async_bridge.py deleted file mode 100644 index a4546fe..0000000 --- a/glpi_python_client/clients/commons/_async_bridge.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Synchronous-to-asynchronous bridge for the GLPI client. - -The bridge inspects every public sync method exposed by the bases of a -subclass, then installs a coroutine wrapper on the subclass that defers -the blocking call to a worker thread. This keeps the synchronous client -as the single source of truth while still exposing a fully asynchronous -public surface. - -Concurrency notes ------------------ -* Each wrapped call runs on a worker thread, which means concurrent - callers contend on OS threads rather than only on the event loop. The - underlying transport mixin protects shared state with a - :class:`threading.Lock` for that reason. -* When many coroutines fan out at once (for example through - :func:`asyncio.gather`) the default :func:`asyncio.to_thread` - executor can become a bottleneck. The - :class:`~glpi_python_client.clients.AsyncGlpiClient` exposes an - optional ``executor`` constructor argument that callers can use to - supply a dedicated :class:`concurrent.futures.ThreadPoolExecutor`. -* Cancellation is best-effort: cancelling the awaiting coroutine - releases the awaiter immediately, but the in-flight HTTP request keeps - running on the worker thread until ``requests`` returns. This matches - the behaviour of the original async client. - -Known limitation — internal ``self``-calls ------------------------------------------- -The bridge wraps *every* public method on the async client class. As a -result, when a synchronous body that is running inside a worker thread -calls another public method through ``self`` (e.g. -``self.search_tickets(...)``), it resolves to the *bridge-wrapped* -coroutine, not the synchronous function. Calling a coroutine without -``await`` produces a dangling coroutine object, not data. - -Any sync method (or generator) that internally calls other public -methods through ``self`` must therefore be given a hand-written async -override that ``await``s (or ``async for``s) those calls on the event -loop. The convention used in this codebase is to place such overrides -in a ``_*_async.py`` companion module (e.g. -``clients/custom/_statistics_async.py``) and wire them into the async -client's MRO *before* the sync mixin that defines the original method. -""" - -from __future__ import annotations - -import asyncio -import functools -import inspect -from collections.abc import Callable -from concurrent.futures import Executor -from typing import Any - -# Sentinel used by the async-generator bridge to signal exhaustion without -# propagating StopIteration through a coroutine (which PEP 479 forbids). -_STOPPED: object = object() - - -def _next_or_stopped(gen: Any) -> Any: - """Return the next item from *gen* or ``_STOPPED`` when exhausted.""" - - try: - return next(gen) - except StopIteration: - return _STOPPED - - -class AsyncBridge: - """Base class that converts inherited sync methods into coroutines. - - The bridge is intended to be mixed into the most-derived async - client class **before** the sync mixins so its - :meth:`__init_subclass__` hook can observe the full MRO and install - coroutine wrappers on the subclass for every public method that the - sync mixins expose. - - Subclasses may also assign a :class:`concurrent.futures.Executor` - instance to ``_executor`` to route every wrapped call through a - dedicated pool. When ``_executor`` is ``None`` the bridge falls back - to :func:`asyncio.to_thread`, which uses the default loop executor. - """ - - _executor: Executor | None = None - - def __init_subclass__(cls, **kwargs: object) -> None: - """Install async wrappers for every inherited public sync method. - - The hook walks the resolution order from the most-derived sync - base downwards and skips: - - * the bridge class itself and :class:`object`, - * names that start with an underscore (private/protected), - * attributes that are not callable, and - * attributes that are already coroutine functions (so the - subclass may declare hand-written async overrides such as - :meth:`get_ticket_context`). - - Each surviving method is wrapped with a coroutine that defers - the blocking call to a worker thread. - """ - - super().__init_subclass__(**kwargs) - seen: set[str] = set() - for base in cls.__mro__: - if base in (cls, AsyncBridge, object): - continue - for name, member in vars(base).items(): - if name in seen or name.startswith("_"): - continue - if not callable(member) or inspect.iscoroutinefunction(member): - continue - if inspect.isasyncgenfunction(member): - continue - # Skip if the subclass already overrides the method with - # a coroutine function or async generator (for example async fan-outs). - existing = getattr(cls, name, None) - if existing is not None and ( - inspect.iscoroutinefunction(existing) - or inspect.isasyncgenfunction(existing) - ): - seen.add(name) - continue - seen.add(name) - if inspect.isgeneratorfunction(member): - setattr(cls, name, _make_async_generator_wrapper(member)) - else: - setattr(cls, name, _make_async_wrapper(member)) - - -def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]: - """Return one coroutine wrapper that runs ``sync_func`` off-thread. - - Parameters - ---------- - sync_func : Callable[..., Any] - Synchronous callable inherited from a sync mixin. - - Returns - ------- - Callable[..., Any] - Coroutine function that schedules ``sync_func`` on the bound - client's executor (or :func:`asyncio.to_thread` when no - executor is configured) and awaits its result. - """ - - @functools.wraps(sync_func) - async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any: - bound = functools.partial(sync_func, self, *args, **kwargs) - if self._executor is not None: - loop = asyncio.get_running_loop() - return await loop.run_in_executor(self._executor, bound) - return await asyncio.to_thread(bound) - - return wrapper - - -def _make_async_generator_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]: - """Return an async generator wrapper for a synchronous generator function. - - Each call to ``next()`` on the underlying sync generator is dispatched - to a worker thread so that the blocking HTTP call inside the generator - body does not block the event loop. - - Parameters - ---------- - sync_func : Callable[..., Any] - Synchronous generator function inherited from a sync mixin. - - Returns - ------- - Callable[..., Any] - Async generator function that yields the same items as the - synchronous generator, one batch at a time, off the event loop. - """ - - @functools.wraps(sync_func) - async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any: - gen = sync_func(self, *args, **kwargs) - while True: - if self._executor is not None: - loop = asyncio.get_running_loop() - item = await loop.run_in_executor(self._executor, _next_or_stopped, gen) - else: - item = await asyncio.to_thread(_next_or_stopped, gen) - if item is _STOPPED: - return - yield item - - return wrapper - - -__all__ = ["AsyncBridge"] diff --git a/glpi_python_client/clients/custom/__init__.py b/glpi_python_client/clients/custom/__init__.py deleted file mode 100644 index 92708d5..0000000 --- a/glpi_python_client/clients/custom/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Custom higher-level helpers built on top of the API mixins. - -The custom package exposes operations that are not advertised by the GLPI -API contract directly but are useful to client applications. Examples -include the aggregated ticket-context view and small reporting utilities -built by combining the contract-aligned CRUD helpers from -:mod:`glpi_python_client.clients.api`. - -Both a synchronous mixin and an asynchronous override are provided for -the helpers that benefit from concurrent fan-out -(:mod:`glpi_python_client.clients.custom._ticket_context` and -:mod:`glpi_python_client.clients.custom._statistics`). The synchronous -mixins are composed into -:class:`~glpi_python_client.clients.GlpiClient`; the async overrides are -composed into :class:`~glpi_python_client.clients.AsyncGlpiClient`. -""" - -from __future__ import annotations - -from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin -from glpi_python_client.clients.custom._statistics import StatisticsMixin -from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin -from glpi_python_client.clients.custom._ticket_context import TicketContextMixin -from glpi_python_client.clients.custom._ticket_context_async import ( - AsyncTicketContextMixin, -) - -__all__ = [ - "AsyncPaginationMixin", - "AsyncStatisticsMixin", - "AsyncTicketContextMixin", - "StatisticsMixin", - "TicketContextMixin", -] diff --git a/glpi_python_client/clients/custom/_pagination_async.py b/glpi_python_client/clients/custom/_pagination_async.py deleted file mode 100644 index 96b83d3..0000000 --- a/glpi_python_client/clients/custom/_pagination_async.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Asynchronous overrides for the three paginated search generators. - -Each of the three ``iter_search_*`` generators in the API mixins -delegates its pagination loop to a ``self.search_*()`` call. When the -:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` -wraps a sync generator it drives ``next()`` on the generator object inside -a worker thread. Inside that thread ``self`` is still the -:class:`~glpi_python_client.clients.AsyncGlpiClient` instance, so -``self.search_tickets(...)`` resolves to the bridge-wrapped coroutine -function and calling it without ``await`` returns a coroutine object -instead of the expected list — triggering a -``RuntimeWarning: coroutine … was never awaited`` and a 500 response. - -This mixin replaces all three generators with native async generators that -``await self.search_*(...)`` directly on the event loop. - -The mixin must be positioned **before** ``TicketMixin``, ``UserMixin``, and -``EntityMixin`` in the :class:`~glpi_python_client.clients.AsyncGlpiClient` -base list so that the bridge's ``__init_subclass__`` hook finds the async -generator via ``getattr`` before it would otherwise wrap the sync version. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator - -from glpi_python_client.models.api_schema.administration._entity import GetEntity -from glpi_python_client.models.api_schema.administration._user import GetUser -from glpi_python_client.models.api_schema.assistance._ticket import GetTicket - - -class AsyncPaginationMixin: - """Async generator overrides for the three paginated search helpers. - - Each override re-implements the simple ``start``-advancing loop of - the synchronous counterpart but ``await``\\ s the underlying - ``search_*`` call so it runs on the event loop rather than inside a - worker-thread-dispatched ``next()`` call where the coroutine would - be dropped on the floor. - """ - - async def iter_search_tickets( - self, - rsql_filter: str = "", - *, - batch_size: int = 50, - sort: str | None = None, - fields: tuple[str, ...] = (), - ) -> AsyncIterator[list[GetTicket]]: - """Yield successive pages of GLPI tickets until exhausted. - - Parameters - ---------- - rsql_filter : str, optional - Raw RSQL filter forwarded as the ``filter`` query parameter. - Empty by default, which lists every visible ticket. - batch_size : int, optional - Number of records requested per page (default 50). - sort : str | None, optional - ``sort`` query parameter forwarded as-is to each page request. - fields : tuple[str, ...], optional - Restricted set of contract field names to request. - - Yields - ------ - list[GetTicket] - One page of tickets per iteration. The last yielded batch may - be shorter than ``batch_size``. - """ - - start = 0 - while True: - batch: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined] - rsql_filter, - limit=batch_size, - start=start, - sort=sort, - fields=fields, - ) - if batch: - yield batch - if len(batch) < batch_size: - break - start += batch_size - - async def iter_search_users( - self, - rsql_filter: str = "", - *, - batch_size: int = 50, - skip_entity: bool = False, - ) -> AsyncIterator[list[GetUser]]: - """Yield successive pages of GLPI users until exhausted. - - Parameters - ---------- - rsql_filter : str, optional - Raw RSQL filter forwarded as the ``filter`` query parameter. - Empty by default, which lists every visible user. - batch_size : int, optional - Number of records requested per page (default 50). - skip_entity : bool, optional - When ``True`` the ``GLPI-Entity`` header is omitted so the - search spans every entity the caller has access to. - - Yields - ------ - list[GetUser] - One page of users per iteration. The last yielded batch may - be shorter than ``batch_size``. - """ - - start = 0 - while True: - batch: list[GetUser] = await self.search_users( # type: ignore[attr-defined] - rsql_filter, - limit=batch_size, - start=start, - skip_entity=skip_entity, - ) - if batch: - yield batch - if len(batch) < batch_size: - break - start += batch_size - - async def iter_search_entities( - self, - rsql_filter: str = "", - *, - batch_size: int = 50, - ) -> AsyncIterator[list[GetEntity]]: - """Yield successive pages of GLPI entities until exhausted. - - Parameters - ---------- - rsql_filter : str, optional - Raw RSQL filter forwarded as the ``filter`` query parameter. - Empty by default, which lists every accessible entity. - batch_size : int, optional - Number of records requested per page (default 50). - - Yields - ------ - list[GetEntity] - One page of entities per iteration. The last yielded batch - may be shorter than ``batch_size``. - """ - - start = 0 - while True: - batch: list[GetEntity] = await self.search_entities( # type: ignore[attr-defined] - rsql_filter, - limit=batch_size, - start=start, - ) - if batch: - yield batch - if len(batch) < batch_size: - break - start += batch_size - - -__all__ = ["AsyncPaginationMixin"] diff --git a/glpi_python_client/clients/custom/_statistics_async.py b/glpi_python_client/clients/custom/_statistics_async.py deleted file mode 100644 index 0cb4914..0000000 --- a/glpi_python_client/clients/custom/_statistics_async.py +++ /dev/null @@ -1,555 +0,0 @@ -"""Asynchronous overrides for the statistics aggregation helper. - -The async mixin overrides :meth:`get_ticket_statistics`, -:meth:`get_task_statistics`, :meth:`get_task_durations`, and -:meth:`get_user_activity` so that every internal ``self.*`` call that -would otherwise be dispatched as a bridge-wrapped coroutine is properly -awaited. Without these overrides the bridge runs the synchronous mixin -bodies in a worker thread where the bridge-wrapped methods on ``self`` -return unawaited coroutines instead of data, producing a -``RuntimeWarning: coroutine … was never awaited`` error. - -:meth:`get_task_statistics` and :meth:`get_task_durations` additionally -dispatch their per-ticket ``list_ticket_tasks`` fan-outs concurrently -using :func:`asyncio.gather`. -""" - -from __future__ import annotations - -import asyncio - -from glpi_python_client._errors import GlpiValidationError -from glpi_python_client.clients.custom._statistics import ( - _LIVE_TICKETS, - _V1_SO_ASSIGNEE, - _V1_SO_REQUESTER, - _V1_TASK_BULK_THRESHOLD, - StatisticsMixin, - TaskDurationsResult, - TaskStatisticsResult, - UserActivityResult, - _entity_key, - _summarize_tasks, -) -from glpi_python_client.models.api_schema.assistance.timeline._task import ( - GetTicketTask, -) - - -class AsyncStatisticsMixin(StatisticsMixin): - """Asynchronous custom statistics with concurrent task fan-out. - - The override calls the bridge-wrapped ``list_ticket_tasks`` for each - ticket identifier and awaits the resulting coroutines together via - :func:`asyncio.gather`. Empty inputs return zeroed totals without - any HTTP traffic. - """ - - async def get_task_statistics( # type: ignore[override] - self, - ticket_ids: list[int], - ) -> TaskStatisticsResult: - """Return task duration totals with concurrent per-ticket fetches. - - Parameters - ---------- - ticket_ids : list[int] - Identifiers of the tickets whose tasks should be aggregated. - An empty list returns zeroed totals without any HTTP call. - - Returns - ------- - TaskStatisticsResult - Mapping with ``ticket_count``, ``task_count``, - ``total_duration``, ``duration_by_user``, and - ``duration_by_ticket`` entries. - """ - - if not ticket_ids: - return TaskStatisticsResult( - ticket_count=0, - task_count=0, - total_duration=0, - duration_by_user={}, - duration_by_ticket={}, - ) - results = await asyncio.gather( - *( - self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined] - for ticket_id in ticket_ids - ) - ) - flattened: list[GetTicketTask] = [task for batch in results for task in batch] - return _summarize_tasks(ticket_ids, flattened) - - async def get_task_durations( # type: ignore[override] - self, - *, - start_date: str | None = None, - end_date: str | None = None, - default_days: int = 30, - entity_id: int | None = None, - entity_name: str | None = None, - user_id: int | None = None, - user_editor_id: int | None = None, - user_recipient_id: int | None = None, - extra_filter: str | None = None, - return_task_details: bool = False, - ) -> TaskDurationsResult: - """Return task duration totals with concurrent per-ticket fetches. - - Overrides the synchronous implementation so that when - ``return_task_details=True`` the per-ticket - :meth:`list_ticket_tasks` calls are dispatched concurrently using - :func:`asyncio.gather`. The date-window, entity, and user filter - logic is identical to the synchronous version. - - Parameters - ---------- - start_date : str | None, optional - ISO ``YYYY-MM-DD`` start of the window (inclusive from - 00:00:00). - end_date : str | None, optional - ISO ``YYYY-MM-DD`` end of the window (inclusive through - 23:59:59). Defaults to today. - default_days : int, optional - Span in days used when ``start_date`` is omitted (default 30). - entity_id : int | None, optional - Restrict to tickets in this entity. - entity_name : str | None, optional - Resolve entity by name and restrict to matched entities. - user_id : int | None, optional - Restrict to tickets where the user is assignee or requester. - user_editor_id : int | None, optional - Restrict to tickets last updated by this user. - user_recipient_id : int | None, optional - Restrict to tickets where this user is the requester. - extra_filter : str | None, optional - Optional raw RSQL fragment appended as an AND clause. - return_task_details : bool, optional - When ``True``, fan-out per-ticket task fetches concurrently - and include a ``tasks`` list in the result. - - Returns - ------- - TaskDurationsResult - Same shape as the synchronous :meth:`get_task_durations`. - """ - - from collections import defaultdict - - from glpi_python_client.clients.commons._filters import ( - rsql_all_filter, - rsql_any_filter, - rsql_contains_filter, - ) - from glpi_python_client.clients.custom._statistics import _resolve_window - - start, end = _resolve_window( - start_date=start_date, - end_date=end_date, - default_days=default_days, - ) - date_filter = f"date_creation=ge={start.isoformat()};" - date_filter += f"date_creation=le={end.isoformat()} 23:59:59" - entity_filter: str | None = None - if entity_id is not None: - entity_filter = f"entity.id=={entity_id}" - elif entity_name is not None: - name_filter = rsql_contains_filter("name", entity_name) or "" - entities = await self.search_entities( # type: ignore[attr-defined] - rsql_filter=name_filter, - limit=200, - ) - if not entities: - return TaskDurationsResult( - start_date=start.isoformat(), - end_date=end.isoformat(), - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - entity_filter = rsql_any_filter( - *(f"entity.id=={e.id}" for e in entities if e.id is not None) - ) - - # Mirrors the synchronous mixin: v2 cannot filter on ticket actors, - # so the id set comes from v1 and is intersected below. The v1 call - # is blocking, so it runs in a worker thread. - actor_ticket_ids: set[int] | None = None - if user_id is not None: - actor_ticket_ids = await asyncio.to_thread( - StatisticsMixin._v1_ticket_ids_for_actor, - self, - user_id, - search_options=(_V1_SO_ASSIGNEE, _V1_SO_REQUESTER), - parameter="user_id", - ) - if not actor_ticket_ids: - return TaskDurationsResult( - start_date=start.isoformat(), - end_date=end.isoformat(), - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - - editor_filter: str | None = None - if user_editor_id is not None: - editor_filter = f"user_editor.id=={user_editor_id}" - - recipient_filter: str | None = None - if user_recipient_id is not None: - recipient_filter = f"user_recipient.id=={user_recipient_id}" - - rsql_filter = ( - rsql_all_filter( - date_filter, - entity_filter, - editor_filter, - recipient_filter, - _LIVE_TICKETS, - extra_filter, - ) - or "" - ) - - ticket_ids: list[int] = [] - ticket_entity_map: dict[int, str] = {} - async for batch in self.iter_search_tickets( # type: ignore[attr-defined] - rsql_filter, - batch_size=200, - ): - for ticket in batch: - if ticket.id is None: - continue - if actor_ticket_ids is not None and ticket.id not in actor_ticket_ids: - continue - ticket_ids.append(ticket.id) - ticket_entity_map[ticket.id] = _entity_key(ticket.entity) - - # Mirrors the synchronous mixin: one bulk v1 sweep replaces the - # per-ticket fan-out for large ticket sets. The v1 call is blocking, - # so it runs in a worker thread. - if self._v1 is not None and len(ticket_ids) >= _V1_TASK_BULK_THRESHOLD: - result = await asyncio.to_thread( - StatisticsMixin._v1_task_statistics, - self, - ticket_ids, - since=start, - ) - else: - result = await self.get_task_statistics(ticket_ids) - - duration_by_entity: defaultdict[str, int] = defaultdict(int) - for tid, dur in result["duration_by_ticket"].items(): - entity_key = ticket_entity_map.get(int(tid), "unknown") - duration_by_entity[entity_key] += int(dur) - - task_details: list[dict[str, object]] | None = None - if return_task_details: - tasks_per_ticket: list[list[GetTicketTask]] = await asyncio.gather( - *( - self.list_ticket_tasks(int(tid)) # type: ignore[attr-defined] - for tid, dur in result["duration_by_ticket"].items() - if int(dur) > 0 - ) - ) - non_zero_tids = [ - int(tid) - for tid, dur in result["duration_by_ticket"].items() - if int(dur) > 0 - ] - task_details = [] - for tid, tasks in zip(non_zero_tids, tasks_per_ticket, strict=True): - for task in tasks: - task_details.append( - { - "task_id": task.id, - "ticket_id": tid, - "duration": int(task.duration or 0), - "user_id": task.user.id if task.user else None, - "user_name": task.user.name if task.user else None, - "date": str(task.date_creation or ""), - } - ) - - return TaskDurationsResult( - start_date=start.isoformat(), - end_date=end.isoformat(), - total_duration=result["total_duration"], - task_count=result["task_count"], - duration_by_user=result["duration_by_user"], - duration_by_entity=dict(duration_by_entity), - tasks=task_details, - ) - - async def get_ticket_statistics( # type: ignore[override] - self, - *, - start_date: str | None = None, - end_date: str | None = None, - default_days: int = 30, - entity_id: int | None = None, - entity_name: str | None = None, - extra_filter: str | None = None, - ) -> dict[str, object]: - """Return ticket counts grouped by entity, status, priority, and type. - - Async override of :meth:`StatisticsMixin.get_ticket_statistics`. - The synchronous base runs in a worker thread where ``self.search_tickets`` - and ``self.search_entities`` resolve to bridge-wrapped coroutines that - would be silently dropped. This override awaits those calls directly on - the event loop instead. - - Parameters - ---------- - start_date : str | None, optional - ISO ``YYYY-MM-DD`` start of the window (inclusive from - 00:00:00). - end_date : str | None, optional - ISO ``YYYY-MM-DD`` end of the window (inclusive through - 23:59:59). Defaults to today. - default_days : int, optional - Span in days used when ``start_date`` is omitted (default 30). - entity_id : int | None, optional - Restrict to tickets in this entity. - entity_name : str | None, optional - Resolve entity by name and restrict to matched entities. - extra_filter : str | None, optional - Optional raw RSQL fragment appended as an AND clause. - - Returns - ------- - dict[str, object] - Same shape as the synchronous :meth:`get_ticket_statistics`. - - Raises - ------ - GlpiValidationError - If ``default_days < 1``, ``start_date`` / ``end_date`` is not a - valid ISO date, or ``start_date`` is after ``end_date``. - """ - - from glpi_python_client.clients.commons._filters import ( - rsql_all_filter, - rsql_any_filter, - rsql_contains_filter, - ) - from glpi_python_client.clients.custom._statistics import ( - _resolve_window, - _summarize_tickets, - ) - - start, end = _resolve_window( - start_date=start_date, - end_date=end_date, - default_days=default_days, - ) - - entity_filter: str | None = None - if entity_id is not None: - entity_filter = f"entity.id=={entity_id}" - elif entity_name is not None: - name_filter = rsql_contains_filter("name", entity_name) or "" - entities = await self.search_entities( # type: ignore[attr-defined] - rsql_filter=name_filter, - limit=200, - ) - if not entities: - return {"entities": {}} - entity_filter = rsql_any_filter( - *(f"entity.id=={e.id}" for e in entities if e.id is not None) - ) - - date_filter = f"date_creation=ge={start.isoformat()};" - date_filter += f"date_creation=le={end.isoformat()} 23:59:59" - query = rsql_all_filter( - date_filter, - entity_filter, - _LIVE_TICKETS, - extra_filter, - ) - tickets = await self.search_tickets( # type: ignore[attr-defined] - rsql_filter=query or "", - limit=200, - ) - return _summarize_tickets(tickets) - - async def get_user_activity( # type: ignore[override] - self, - *, - user_id: int | None = None, - username: str | None = None, - realname: str | None = None, - firstname: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - default_days: int = 30, - ) -> UserActivityResult: - """Return per-user GLPI activity aggregated across tickets and tasks. - - Async override of :meth:`StatisticsMixin.get_user_activity`. The - synchronous base calls ``self.search_users``, ``self.iter_search_tickets``, - and ``self.get_task_durations`` through ``self``, which on the async - client resolve to bridge-wrapped coroutines. This override awaits those - calls and uses ``async for`` on the async generator. - - Parameters - ---------- - user_id : int | None, optional - Identify the user by GLPI numeric identifier. - username : str | None, optional - Filter by username (substring match). - realname : str | None, optional - Filter by family name (substring match). - firstname : str | None, optional - Filter by given name (substring match). - start_date : str | None, optional - ISO ``YYYY-MM-DD`` start of the activity window (inclusive - from 00:00:00). - end_date : str | None, optional - ISO ``YYYY-MM-DD`` end of the activity window (inclusive - through 23:59:59). Defaults to today. - default_days : int, optional - Span in days used when ``start_date`` is omitted (default 30). - - Returns - ------- - UserActivityResult - Same shape as the synchronous :meth:`get_user_activity`. - - Raises - ------ - GlpiValidationError - If none of ``user_id``, ``username``, ``realname``, or - ``firstname`` are supplied, or if the supplied criteria match - no GLPI users. - """ - - from glpi_python_client.clients.commons._filters import ( - rsql_all_filter, - rsql_contains_filter, - ) - from glpi_python_client.clients.custom._statistics import ( - UserActivityEntry, - UserActivityResult, - _merge_task_durations, - _resolve_window, - ) - - if all(v is None for v in (user_id, username, realname, firstname)): - raise GlpiValidationError( - "At least one of user_id, username, realname, or " - "firstname must be supplied" - ) - - start, end = _resolve_window( - start_date=start_date, - end_date=end_date, - default_days=default_days, - ) - - if user_id is not None: - resolved_user_ids: list[int] = [user_id] - user_display_map: dict[int, str] = {user_id: str(user_id)} - else: - name_parts = [ - rsql_contains_filter("username", username) if username else None, - rsql_contains_filter("realname", realname) if realname else None, - rsql_contains_filter("firstname", firstname) if firstname else None, - ] - user_rsql = rsql_all_filter(*name_parts) or "" - matched_users = await self.search_users( # type: ignore[attr-defined] - rsql_filter=user_rsql, - limit=200, - ) - if not matched_users: - raise GlpiValidationError("No users matched the supplied criteria") - resolved_user_ids = [u.id for u in matched_users if u.id is not None] - user_display_map = { - u.id: ( - f"{u.firstname or ''} {u.realname or ''}".strip() - or u.username - or str(u.id) - ) - for u in matched_users - if u.id is not None - } - - date_range = f"date_creation=ge={start.isoformat()};" - date_range += f"date_creation=le={end.isoformat()} 23:59:59" - - # Mirrors the synchronous mixin: the window is walked once for all - # users, and the per-role split comes from intersecting v1 id sets. - window_filter = rsql_all_filter(date_range, _LIVE_TICKETS) or "" - window_ids: set[int] = set() - async for batch in self.iter_search_tickets( # type: ignore[attr-defined] - window_filter, - batch_size=200, - ): - for ticket in batch: - if ticket.id is not None: - window_ids.add(ticket.id) - - users_output: dict[str, UserActivityEntry] = {} - for uid in resolved_user_ids: - display_key = user_display_map.get(uid, str(uid)) - assigned_ids, requested_ids = await asyncio.gather( - asyncio.to_thread( - StatisticsMixin._v1_ticket_ids_for_actor, - self, - uid, - search_options=(_V1_SO_ASSIGNEE,), - parameter="user_id", - ), - asyncio.to_thread( - StatisticsMixin._v1_ticket_ids_for_actor, - self, - uid, - search_options=(_V1_SO_REQUESTER,), - parameter="user_id", - ), - ) - tech_count = len(window_ids & assigned_ids) - recipient_count = len(window_ids & requested_ids) - task_dur = await self.get_task_durations( - start_date=start_date, - end_date=end_date, - default_days=default_days, - user_id=uid, - ) - task_dur_clean: TaskDurationsResult = TaskDurationsResult( - start_date=task_dur["start_date"], - end_date=task_dur["end_date"], - total_duration=task_dur["total_duration"], - task_count=task_dur["task_count"], - duration_by_user=dict(task_dur["duration_by_user"]), - duration_by_entity=dict(task_dur["duration_by_entity"]), - tasks=None, - ) - - if display_key in users_output: - existing = users_output[display_key] - existing["user_ids"] = [*existing["user_ids"], uid] - existing["tickets_as_technician"] += tech_count - existing["tickets_as_recipient"] += recipient_count - existing["task_durations"] = _merge_task_durations( - existing["task_durations"], task_dur_clean - ) - else: - users_output[display_key] = UserActivityEntry( - user_ids=[uid], - tickets_as_technician=tech_count, - tickets_as_recipient=recipient_count, - task_durations=task_dur_clean, - ) - - return UserActivityResult(users=users_output) - - -__all__ = ["AsyncStatisticsMixin"] diff --git a/glpi_python_client/clients/custom/_ticket_context_async.py b/glpi_python_client/clients/custom/_ticket_context_async.py deleted file mode 100644 index b1f386b..0000000 --- a/glpi_python_client/clients/custom/_ticket_context_async.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Asynchronous override for the ticket-context aggregation helper. - -The async mixin overrides :meth:`get_ticket_context` so the five -underlying GLPI calls are dispatched concurrently using -:func:`asyncio.gather`. The endpoint methods themselves are exposed as -coroutines by the -:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` -applied to :class:`~glpi_python_client.clients.AsyncGlpiClient`, so this -override simply awaits the bridge-wrapped versions concurrently. -""" - -from __future__ import annotations - -import asyncio - -from glpi_python_client.clients.commons._constants import GlpiId -from glpi_python_client.clients.custom._ticket_context import TicketContextMixin -from glpi_python_client.models.custom_schema._ticket_context import GlpiTicketContext - - -class AsyncTicketContextMixin(TicketContextMixin): - """Asynchronous ticket-context aggregation helper. - - The override calls the five underlying GLPI endpoint helpers, which - the async bridge has wrapped as coroutines, and awaits them - concurrently with :func:`asyncio.gather`. The async runtime keeps - every concurrent worker on a distinct thread because the underlying - HTTP layer is still backed by the blocking ``requests`` library. - """ - - async def get_ticket_context( # type: ignore[override] - self, ticket_id: GlpiId - ) -> GlpiTicketContext: - """Return one aggregated ticket context with concurrent fan-out. - - Parameters - ---------- - ticket_id : GlpiId - Numeric identifier of the ticket to assemble. - - Returns - ------- - GlpiTicketContext - Aggregated view bundling the primary ticket together with - its tasks, followups, solutions, and timeline document - links. - - Raises - ------ - GlpiStatusError - If any of the underlying GLPI calls returns a non-success - HTTP status. - """ - - ticket, tasks, followups, solutions, documents = await asyncio.gather( - self.get_ticket(ticket_id), # type: ignore[attr-defined] - self.list_ticket_tasks(ticket_id), # type: ignore[attr-defined] - self.list_ticket_followups(ticket_id), # type: ignore[attr-defined] - self.list_ticket_solutions(ticket_id), # type: ignore[attr-defined] - self.list_ticket_timeline_documents( # type: ignore[attr-defined] - ticket_id - ), - ) - return GlpiTicketContext( - ticket=ticket, - tasks=tasks, - followups=followups, - solutions=solutions, - documents=documents, - ) - - -__all__ = ["AsyncTicketContextMixin"] diff --git a/glpi_python_client/clients/custom/tests/test_statistics_async.py b/glpi_python_client/clients/custom/tests/test_statistics_async.py deleted file mode 100644 index 778d519..0000000 --- a/glpi_python_client/clients/custom/tests/test_statistics_async.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Unit tests for the asynchronous statistics mixin. - -These tests stub :meth:`iter_search_tickets`, :meth:`list_ticket_tasks`, -:meth:`search_entities`, and :meth:`get_task_statistics` directly on an -:class:`AsyncGlpiClient` instance so the async aggregations exercise -their real summarization logic without any HTTP call. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from typing import Any - -import pytest - -from glpi_python_client import AsyncGlpiClient -from glpi_python_client.models.api_schema._common import ( - IdNameCompletenameRef, - IdNameRef, -) -from glpi_python_client.models.api_schema.administration._entity import GetEntity -from glpi_python_client.models.api_schema.assistance._ticket import GetTicket -from glpi_python_client.models.api_schema.assistance.timeline._task import ( - GetTicketTask, -) -from glpi_python_client.testing.utils import make_async_client - - -@pytest.fixture -def aclient() -> AsyncGlpiClient: - """Return one in-memory asynchronous test client.""" - - return make_async_client() - - -def _make_ticket(ticket_id: int, entity_id: int | None = 1) -> GetTicket: - """Build a minimal ``GetTicket`` for the duration aggregations.""" - - payload: dict[str, Any] = { - "id": ticket_id, - "name": f"t{ticket_id}", - "content": "c", - } - if entity_id is not None: - payload["entity"] = IdNameCompletenameRef( - id=entity_id, name=f"E{entity_id}", completename=f"E{entity_id}" - ) - return GetTicket(**payload) - - -async def _aiter_batches( - batches: list[list[GetTicket]], -) -> AsyncIterator[list[GetTicket]]: - """Yield ticket batches as an async iterator (mirrors the bridge wrapper).""" - - for batch in batches: - yield batch - - -async def test_async_get_task_durations_empty_iterator( - aclient: AsyncGlpiClient, -) -> None: - """An empty ticket iterator returns zeroed totals without task fetches.""" - - def fake_iter( - rsql_filter: str = "", *, batch_size: int = 200 - ) -> AsyncIterator[list[GetTicket]]: - return _aiter_batches([]) - - aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] - result = await aclient.get_task_durations( - start_date="2026-01-01", end_date="2026-01-31" - ) - assert result["total_duration"] == 0 - assert result["task_count"] == 0 - assert result["duration_by_entity"] == {} - assert result["tasks"] is None - await aclient.close() - - -async def test_async_get_task_durations_entity_grouping( - aclient: AsyncGlpiClient, -) -> None: - """``duration_by_entity`` is grouped from the per-ticket statistics.""" - - tickets = [_make_ticket(1, entity_id=10), _make_ticket(2, entity_id=20)] - - def fake_iter( - rsql_filter: str = "", *, batch_size: int = 200 - ) -> AsyncIterator[list[GetTicket]]: - return _aiter_batches([tickets]) - - async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]: - return { - "ticket_count": 2, - "task_count": 2, - "total_duration": 1200, - "duration_by_user": {"42": 1200}, - "duration_by_ticket": {1: 600, 2: 600}, - } - - aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] - aclient.get_task_statistics = fake_stats # type: ignore[method-assign] - result = await aclient.get_task_durations( - start_date="2026-01-01", end_date="2026-01-31" - ) - assert result["duration_by_entity"] == {"10": 600, "20": 600} - assert result["tasks"] is None - await aclient.close() - - -async def test_async_get_task_durations_return_task_details( - aclient: AsyncGlpiClient, -) -> None: - """``return_task_details=True`` returns a flat task list with metadata.""" - - tickets = [_make_ticket(1, entity_id=10)] - - def fake_iter( - rsql_filter: str = "", *, batch_size: int = 200 - ) -> AsyncIterator[list[GetTicket]]: - return _aiter_batches([tickets]) - - async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]: - return { - "ticket_count": 1, - "task_count": 1, - "total_duration": 300, - "duration_by_user": {"7": 300}, - "duration_by_ticket": {1: 300}, - } - - async def fake_list_tasks(ticket_id: int) -> list[GetTicketTask]: - return [ - GetTicketTask( - id=99, - tickets_id=ticket_id, - duration=300, - user=IdNameRef(id=7, name="alice"), - ) - ] - - aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] - aclient.get_task_statistics = fake_stats # type: ignore[method-assign] - aclient.list_ticket_tasks = fake_list_tasks # type: ignore[method-assign] - result = await aclient.get_task_durations( - start_date="2026-01-01", - end_date="2026-01-31", - return_task_details=True, - ) - tasks = result["tasks"] - assert isinstance(tasks, list) - assert len(tasks) == 1 - assert tasks[0]["task_id"] == 99 - assert tasks[0]["ticket_id"] == 1 - assert tasks[0]["duration"] == 300 - assert tasks[0]["user_id"] == 7 - await aclient.close() - - -async def test_async_get_task_durations_entity_name_no_match( - aclient: AsyncGlpiClient, -) -> None: - """When ``entity_name`` matches nothing the helper short-circuits.""" - - async def fake_search_entities( - rsql_filter: str = "", *, limit: int = 50 - ) -> list[GetEntity]: - return [] - - aclient.search_entities = fake_search_entities # type: ignore[method-assign] - result = await aclient.get_task_durations( - start_date="2026-01-01", - end_date="2026-01-31", - entity_name="nope", - ) - assert result["total_duration"] == 0 - assert result["task_count"] == 0 - assert result["duration_by_entity"] == {} - assert result["tasks"] is None - await aclient.close() - - -async def test_async_get_task_durations_entity_name_match( - aclient: AsyncGlpiClient, -) -> None: - """When ``entity_name`` matches entities the helper combines RSQL filters.""" - - tickets = [_make_ticket(1, entity_id=42)] - - async def fake_search_entities( - rsql_filter: str = "", *, limit: int = 50 - ) -> list[GetEntity]: - return [GetEntity(id=42, name="acme", completename="root > acme")] - - captured: dict[str, str] = {} - - def fake_iter( - rsql_filter: str = "", *, batch_size: int = 200 - ) -> AsyncIterator[list[GetTicket]]: - captured["filter"] = rsql_filter - return _aiter_batches([tickets]) - - async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]: - return { - "ticket_count": 1, - "task_count": 0, - "total_duration": 0, - "duration_by_user": {}, - "duration_by_ticket": {1: 0}, - } - - class _FakeV1: - """v1 stand-in: ``user_id`` is resolved through the v1 search.""" - - def request_json(self, method: str, path: str, **kwargs: Any) -> object: - return {"totalcount": 1, "data": [{"2": 1}]} - - def close(self) -> None: - """No-op.""" - - aclient.search_entities = fake_search_entities # type: ignore[method-assign] - aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] - aclient.get_task_statistics = fake_stats # type: ignore[method-assign] - aclient._v1 = _FakeV1() # type: ignore[assignment] - - result = await aclient.get_task_durations( - start_date="2026-01-01", - end_date="2026-01-31", - entity_name="acme", - user_id=7, - user_editor_id=8, - user_recipient_id=9, - extra_filter="status==1", - ) - assert result["task_count"] == 0 - assert "entity.id==42" in captured["filter"] - assert "user_editor.id==8" in captured["filter"] - assert "user_recipient.id==9" in captured["filter"] - assert "status==1" in captured["filter"] - assert "is_deleted==false" in captured["filter"] - # ``user_id`` selects on actors, which v2 cannot express, so it is - # resolved through v1 and must not appear in the v2 filter at all. - assert "users_id_assign" not in captured["filter"] - assert "user_id" not in captured["filter"] - # None of the dropped v1 spellings may come back. - for dead in ("entities_id", "users_id_lastupdater", "users_id_requester"): - assert dead not in captured["filter"] - await aclient.close() diff --git a/glpi_python_client/clients/tests/test_async_branches.py b/glpi_python_client/clients/tests/test_async_branches.py deleted file mode 100644 index 016059b..0000000 --- a/glpi_python_client/clients/tests/test_async_branches.py +++ /dev/null @@ -1,792 +0,0 @@ -"""Tests for async-only branches: bridge executor, custom mixins, close.""" - -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor -from typing import Any - -import pytest - -from glpi_python_client import AsyncGlpiClient, GlpiValidationError -from glpi_python_client.testing.utils import FakeResponse, make_async_client - - -class _FakeV1Ids: - """Minimal v1 session returning a fixed ticket-id set for any actor query. - - ``get_user_activity`` resolves assignee/requester through v1 because the - v2 API has no filterable assignee, so these tests need a v1 stand-in. - """ - - def __init__(self, ticket_ids: list[int]) -> None: - self.ticket_ids = ticket_ids - - def request_json(self, method: str, path: str, **kwargs: Any) -> object: - rows = [{"2": ticket_id} for ticket_id in self.ticket_ids] - return {"totalcount": len(rows), "data": rows} - - def close(self) -> None: - """No-op; the real session is closed with the client.""" - - -class _StubTicket: - """Stand-in ticket exposing only what the aggregation reads.""" - - def __init__(self, ticket_id: int) -> None: - self.id = ticket_id - self.entity = None - - -async def test_async_bridge_uses_provided_executor() -> None: - """A custom executor is used to dispatch the wrapped sync call.""" - - with ThreadPoolExecutor(max_workers=1, thread_name_prefix="glpi-test") as pool: - client = make_async_client(executor=pool) - captured: dict[str, str] = {} - - def _get( - endpoint: str, params: Any = None, skip_entity: bool = False - ) -> FakeResponse: - import threading - - captured["thread"] = threading.current_thread().name - return FakeResponse(status_code=200, payload=[]) - - client._get_request = _get # type: ignore[method-assign] - await client.search_tickets("status==1") - await client.close() - assert captured["thread"].startswith("glpi-test") - - -async def test_async_get_ticket_context_fan_out_uses_gather() -> None: - """The async override aggregates the five endpoint coroutines concurrently.""" - - client = make_async_client() - calls: list[str] = [] - - def _get( - endpoint: str, params: Any = None, skip_entity: bool = False - ) -> FakeResponse: - calls.append(endpoint) - if endpoint.endswith("/Timeline/Followup"): - return FakeResponse(status_code=200, payload=[]) - if endpoint.endswith("/Timeline/Solution"): - return FakeResponse(status_code=200, payload=[]) - if endpoint.endswith("/Timeline/Task"): - return FakeResponse(status_code=200, payload=[]) - if endpoint.endswith("/Timeline/Document"): - return FakeResponse(status_code=200, payload=[]) - return FakeResponse( - status_code=200, - payload={"id": 42, "name": "t", "content": "

c

"}, - ) - - client._get_request = _get # type: ignore[method-assign] - ctx = await client.get_ticket_context(42) - assert ctx.ticket.id == 42 - assert any("Timeline/Task" in c for c in calls) - assert any("Timeline/Followup" in c for c in calls) - assert any("Timeline/Solution" in c for c in calls) - assert any("Timeline/Document" in c for c in calls) - await client.close() - - -async def test_async_get_task_statistics_with_tickets_uses_gather() -> None: - """The async override fetches per-ticket tasks concurrently.""" - - client = make_async_client() - seen: list[str] = [] - - def _get( - endpoint: str, params: Any = None, skip_entity: bool = False - ) -> FakeResponse: - seen.append(endpoint) - return FakeResponse(status_code=200, payload=[]) - - client._get_request = _get # type: ignore[method-assign] - stats = await client.get_task_statistics([1, 2, 3]) - assert stats["ticket_count"] == 3 - assert stats["task_count"] == 0 - assert sum(1 for e in seen if "/Timeline/Task" in e) == 3 - await client.close() - - -async def test_async_get_task_statistics_empty_short_circuits() -> None: - """An empty ticket list returns zeroed totals without HTTP traffic.""" - - client = make_async_client() - - def _fail(*_args: Any, **_kwargs: Any) -> FakeResponse: - pytest.fail("no HTTP call expected for empty ticket list") - - client._get_request = _fail # type: ignore[method-assign] - stats = await client.get_task_statistics([]) - assert stats == { - "ticket_count": 0, - "task_count": 0, - "total_duration": 0, - "duration_by_user": {}, - "duration_by_ticket": {}, - } - await client.close() - - -async def test_async_close_closes_v1_session_when_configured() -> None: - """The async ``close`` also closes the optional v1 fallback session.""" - - client = AsyncGlpiClient( - glpi_api_url="https://glpi.example.test/api.php/v2", - username="u", - password="p", - v1_base_url="https://glpi.example.test/apirest.php", - v1_user_token="user-token", - v1_app_token="app-token", - ) - assert client._v1 is not None - await client.close() - assert client._closed is True - - -async def test_async_from_env_accepts_executor() -> None: - """``AsyncGlpiClient.from_env`` accepts an executor and forwards it.""" - - env = { - "GLPI_API_URL": "https://glpi.example.test/api.php/v2", - "GLPI_USERNAME": "u", - "GLPI_PASSWORD": "p", - } - with ThreadPoolExecutor(max_workers=1) as pool: - client = AsyncGlpiClient.from_env(env=env, executor=pool) - try: - assert client._executor is pool - finally: - await client.close() - - -async def test_async_generator_wrapper_yields_then_stops_default_executor() -> None: - """The bridge wrapper drives a sync generator function to completion.""" - - from glpi_python_client.clients.commons._async_bridge import ( - AsyncBridge, - _make_async_generator_wrapper, - ) - - def sync_gen(self: AsyncBridge, n: int) -> Any: - for i in range(n): - yield [i] - - wrapper = _make_async_generator_wrapper(sync_gen) - - class _Owner(AsyncBridge): - pass - - owner = _Owner() - collected: list[list[int]] = [] - async for batch in wrapper(owner, 3): - collected.append(batch) - assert collected == [[0], [1], [2]] - - -async def test_async_generator_wrapper_with_executor() -> None: - """The wrapper dispatches generator advancement to the supplied executor.""" - - from glpi_python_client.clients.commons._async_bridge import ( - AsyncBridge, - _make_async_generator_wrapper, - ) - - captured_threads: list[str] = [] - - def sync_gen(self: AsyncBridge) -> Any: - import threading - - captured_threads.append(threading.current_thread().name) - yield ["one"] - captured_threads.append(threading.current_thread().name) - - wrapper = _make_async_generator_wrapper(sync_gen) - - with ThreadPoolExecutor(max_workers=1, thread_name_prefix="glpi-gen") as pool: - - class _Owner(AsyncBridge): - pass - - owner = _Owner() - owner._executor = pool - batches: list[list[str]] = [] - async for batch in wrapper(owner): - batches.append(batch) - assert batches == [["one"]] - assert captured_threads - assert all(name.startswith("glpi-gen") for name in captured_threads) - - -# --------------------------------------------------------------------------- -# AsyncPaginationMixin — iter_search_tickets -# --------------------------------------------------------------------------- - - -async def test_async_iter_search_tickets_single_page() -> None: - """A response shorter than batch_size yields one batch then stops.""" - - client = make_async_client() - call_count = 0 - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - sort: str | None = None, - fields: tuple[str, ...] = (), - ) -> list[Any]: - nonlocal call_count - call_count += 1 - return [{"id": 1, "name": "t1", "content": "c"}] - - client.search_tickets = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_tickets("status==1", batch_size=50): - batches.append(batch) - assert call_count == 1 - assert len(batches) == 1 - assert len(batches[0]) == 1 - await client.close() - - -async def test_async_iter_search_tickets_multi_page_stops_on_short_batch() -> None: - """Iteration stops after the first batch shorter than batch_size.""" - - client = make_async_client() - responses = [ - [ - {"id": 1, "name": "a", "content": "c"}, - {"id": 2, "name": "b", "content": "c"}, - ], - [{"id": 3, "name": "c", "content": "c"}], - ] - call_count = 0 - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - sort: str | None = None, - fields: tuple[str, ...] = (), - ) -> list[Any]: - nonlocal call_count - result = responses[min(call_count, len(responses) - 1)] - call_count += 1 - return result - - client.search_tickets = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_tickets("", batch_size=2): - batches.append(batch) - assert call_count == 2 - assert len(batches) == 2 - assert sum(len(b) for b in batches) == 3 - await client.close() - - -async def test_async_iter_search_tickets_empty_page_not_yielded() -> None: - """An empty response is not yielded but still terminates the loop.""" - - client = make_async_client() - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - sort: str | None = None, - fields: tuple[str, ...] = (), - ) -> list[Any]: - return [] - - client.search_tickets = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_tickets("status==1", batch_size=50): - batches.append(batch) - assert batches == [] - await client.close() - - -# --------------------------------------------------------------------------- -# AsyncPaginationMixin — iter_search_users -# --------------------------------------------------------------------------- - - -async def test_async_iter_search_users_single_page() -> None: - """A response shorter than batch_size yields one batch then stops.""" - - client = make_async_client() - call_count = 0 - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - skip_entity: bool = False, - ) -> list[Any]: - nonlocal call_count - call_count += 1 - return [{"id": 1, "username": "alice"}] - - client.search_users = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_users("username==alice", batch_size=50): - batches.append(batch) - assert call_count == 1 - assert len(batches) == 1 - await client.close() - - -async def test_async_iter_search_users_multi_page_stops_on_short_batch() -> None: - """Iteration stops after the first short user batch.""" - - client = make_async_client() - responses = [ - [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}], - [{"id": 3, "username": "carol"}], - ] - call_count = 0 - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - skip_entity: bool = False, - ) -> list[Any]: - nonlocal call_count - result = responses[min(call_count, len(responses) - 1)] - call_count += 1 - return result - - client.search_users = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_users("", batch_size=2): - batches.append(batch) - assert call_count == 2 - assert sum(len(b) for b in batches) == 3 - await client.close() - - -# --------------------------------------------------------------------------- -# AsyncPaginationMixin — iter_search_entities -# --------------------------------------------------------------------------- - - -async def test_async_iter_search_entities_single_page() -> None: - """A response shorter than batch_size yields one batch then stops.""" - - client = make_async_client() - call_count = 0 - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - ) -> list[Any]: - nonlocal call_count - call_count += 1 - return [{"id": 1, "name": "root"}] - - client.search_entities = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_entities("", batch_size=50): - batches.append(batch) - assert call_count == 1 - assert len(batches) == 1 - await client.close() - - -async def test_async_iter_search_entities_multi_page_stops_on_short_batch() -> None: - """Iteration stops after the first short entity batch.""" - - client = make_async_client() - responses = [ - [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], - [{"id": 3, "name": "c"}], - ] - call_count = 0 - - async def fake_search( - rsql_filter: str = "", - *, - limit: int = 50, - start: int = 0, - ) -> list[Any]: - nonlocal call_count - result = responses[min(call_count, len(responses) - 1)] - call_count += 1 - return result - - client.search_entities = fake_search # type: ignore[method-assign] - batches: list[Any] = [] - async for batch in client.iter_search_entities("", batch_size=2): - batches.append(batch) - assert call_count == 2 - assert sum(len(b) for b in batches) == 3 - await client.close() - - -# --------------------------------------------------------------------------- -# AsyncStatisticsMixin — get_task_durations with entity_id -# --------------------------------------------------------------------------- - - -async def test_async_get_task_durations_with_entity_id() -> None: - """Providing entity_id builds the entity filter without an HTTP lookup.""" - - client = make_async_client() - search_calls: list[str] = [] - - async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - search_calls.append(rsql_filter) - return [] - - async def fake_iter( - rsql_filter: str = "", - **kwargs: Any, - ) -> Any: - return - yield # make it an async generator - - async def fake_task_stats(ticket_ids: list[int]) -> Any: - return { - "total_duration": 0, - "task_count": 0, - "duration_by_user": {}, - "duration_by_ticket": {}, - } - - client.search_tickets = fake_search_tickets # type: ignore[method-assign] - client.iter_search_tickets = fake_iter # type: ignore[method-assign] - client.get_task_statistics = fake_task_stats # type: ignore[method-assign] - - result = await client.get_task_durations(entity_id=5) - assert result["total_duration"] == 0 - assert any("entities_id==5" in c for c in search_calls) or True - await client.close() - - -# --------------------------------------------------------------------------- -# AsyncStatisticsMixin — get_ticket_statistics -# --------------------------------------------------------------------------- - - -async def test_async_get_ticket_statistics_returns_summary() -> None: - """get_ticket_statistics awaits search_tickets and summarises results.""" - - client = make_async_client() - - async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - # Return empty list — _summarize_tickets([]) is valid and avoids - # constructing full GetTicket model objects in this smoke test. - return [] - - client.search_tickets = fake_search_tickets # type: ignore[method-assign] - - result = await client.get_ticket_statistics() - assert isinstance(result, dict) - assert "entities" in result - await client.close() - - -async def test_async_get_ticket_statistics_with_entity_name_no_match() -> None: - """When entity lookup returns nothing, an empty dict is returned early.""" - - client = make_async_client() - - async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [] - - client.search_entities = fake_search_entities # type: ignore[method-assign] - - result = await client.get_ticket_statistics(entity_name="nonexistent") - assert result == {"entities": {}} - await client.close() - - -async def test_async_get_ticket_statistics_with_entity_id() -> None: - """Providing entity_id builds the filter without calling search_entities.""" - - client = make_async_client() - entity_calls: list[str] = [] - - async def fake_search_entities(**kwargs: Any) -> list[Any]: - entity_calls.append("called") - return [] - - async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [] - - client.search_entities = fake_search_entities # type: ignore[method-assign] - client.search_tickets = fake_search_tickets # type: ignore[method-assign] - - result = await client.get_ticket_statistics(entity_id=3) - assert entity_calls == [] - assert "entities" in result - await client.close() - - -# --------------------------------------------------------------------------- -# AsyncStatisticsMixin — get_user_activity -# --------------------------------------------------------------------------- - - -async def test_async_get_user_activity_raises_without_criteria() -> None: - """``GlpiValidationError`` is raised when no user criteria are supplied. - - ``GlpiValidationError`` inherits ``ValueError`` so existing callers that - catch the broader type keep working. - """ - - client = make_async_client() - with pytest.raises(GlpiValidationError, match="At least one of") as excinfo: - await client.get_user_activity() - assert isinstance(excinfo.value, ValueError) - await client.close() - - -async def test_async_get_user_activity_by_user_id() -> None: - """get_user_activity accepts user_id and returns a UserActivityResult.""" - - client = make_async_client() - - async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any: - if False: - yield [] - - async def fake_task_durations(**kwargs: Any) -> Any: - from glpi_python_client.clients.custom._statistics import TaskDurationsResult - - return TaskDurationsResult( - start_date="2025-01-01", - end_date="2025-01-31", - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - - client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign] - client.get_task_durations = fake_task_durations # type: ignore[method-assign] - client._v1 = _FakeV1Ids([]) # type: ignore[assignment] - - result = await client.get_user_activity(user_id=42) - assert "users" in result - await client.close() - - -async def test_async_get_user_activity_by_username_no_match_raises() -> None: - """``GlpiValidationError`` is raised when no users match the criteria. - - ``GlpiValidationError`` inherits ``ValueError`` so existing callers that - catch the broader type keep working. - """ - - client = make_async_client() - - async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [] - - client.search_users = fake_search_users # type: ignore[method-assign] - - with pytest.raises(GlpiValidationError, match="No users matched") as excinfo: - await client.get_user_activity(username="ghost") - assert isinstance(excinfo.value, ValueError) - await client.close() - - -async def test_async_get_user_activity_by_username() -> None: - """get_user_activity resolves username to user_id then aggregates.""" - - from glpi_python_client.models.api_schema.administration._user import GetUser - - client = make_async_client() - - async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [GetUser(id=7, username="alice", realname="A", firstname="B")] - - async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any: - if False: - yield [] - - async def fake_task_durations(**kwargs: Any) -> Any: - from glpi_python_client.clients.custom._statistics import TaskDurationsResult - - return TaskDurationsResult( - start_date="2025-01-01", - end_date="2025-01-31", - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - - client.search_users = fake_search_users # type: ignore[method-assign] - client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign] - client.get_task_durations = fake_task_durations # type: ignore[method-assign] - client._v1 = _FakeV1Ids([]) # type: ignore[assignment] - - result = await client.get_user_activity(username="alice") - assert "users" in result - await client.close() - - -async def test_async_get_ticket_statistics_with_entity_name_found() -> None: - """When entity lookup returns matches, ticket filter uses their IDs.""" - - from glpi_python_client.models.api_schema.administration._entity import GetEntity - - client = make_async_client() - - async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [GetEntity(id=10, name="IT")] - - async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [] - - client.search_entities = fake_search_entities # type: ignore[method-assign] - client.search_tickets = fake_search_tickets # type: ignore[method-assign] - - result = await client.get_ticket_statistics(entity_name="IT") - assert isinstance(result, dict) - assert "entities" in result - await client.close() - - -async def test_async_get_user_activity_counts_ticket_batches() -> None: - """Tech/recipient counts increase when iter_search_tickets yields batches.""" - - from glpi_python_client.clients.custom._statistics import TaskDurationsResult - - client = make_async_client() - - async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any: - yield [_StubTicket(1)] - - async def fake_task_durations(**kwargs: Any) -> Any: - return TaskDurationsResult( - start_date="2025-01-01", - end_date="2025-01-31", - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - - client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign] - client.get_task_durations = fake_task_durations # type: ignore[method-assign] - # Ticket 1 is in the window and is linked to the user under both roles. - client._v1 = _FakeV1Ids([1]) # type: ignore[assignment] - - result = await client.get_user_activity(user_id=99) - entry = list(result["users"].values()) - assert entry[0]["tickets_as_technician"] == 1 - assert entry[0]["tickets_as_recipient"] == 1 - await client.close() - - -async def test_async_get_user_activity_counts_are_role_specific() -> None: - """Assignee and requester counts come from independent v1 id sets. - - The previous implementation sent v1 field names to v2, which silently - ignored them, so both counts collapsed to "every ticket in the window" - and were always equal. Here the window holds two tickets but the user - is linked to only one, under one role. - """ - - from glpi_python_client.clients.custom._statistics import TaskDurationsResult - - client = make_async_client() - - async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any: - yield [_StubTicket(1), _StubTicket(2)] - - async def fake_task_durations(**kwargs: Any) -> Any: - return TaskDurationsResult( - start_date="2025-01-01", - end_date="2025-01-31", - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - - class _RoleAwareV1: - def request_json(self, method: str, path: str, **kwargs: Any) -> object: - params = kwargs.get("params") or {} - option = int(str(params.get("criteria[0][field]"))) - # searchOption 5 == assignee, 4 == requester. - ids = [1] if option == 5 else [] - rows = [{"2": ticket_id} for ticket_id in ids] - return {"totalcount": len(rows), "data": rows} - - def close(self) -> None: - """No-op; the real session is closed with the client.""" - - client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign] - client.get_task_durations = fake_task_durations # type: ignore[method-assign] - client._v1 = _RoleAwareV1() # type: ignore[assignment] - - result = await client.get_user_activity(user_id=99) - entry = next(iter(result["users"].values())) - assert entry["tickets_as_technician"] == 1 - assert entry["tickets_as_recipient"] == 0 - await client.close() - - -async def test_async_get_user_activity_merges_duplicate_display_keys() -> None: - """Two users with the same display name are merged into one entry.""" - - from glpi_python_client.clients.custom._statistics import TaskDurationsResult - from glpi_python_client.models.api_schema.administration._user import GetUser - - client = make_async_client() - - # Both users have no firstname/realname → display key is just username "" or id - # Use users with empty names so they get the same display key via str(id) fallback - # Actually easier: give them same realname+firstname so display key collides - user_a = GetUser(id=1, username="a", realname="Smith", firstname="John") - user_b = GetUser(id=2, username="b", realname="Smith", firstname="John") - - async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]: - return [user_a, user_b] - - async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any: - if False: - yield [] - - async def fake_task_durations(**kwargs: Any) -> Any: - return TaskDurationsResult( - start_date="2025-01-01", - end_date="2025-01-31", - total_duration=0, - task_count=0, - duration_by_user={}, - duration_by_entity={}, - tasks=None, - ) - - client.search_users = fake_search_users # type: ignore[method-assign] - client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign] - client.get_task_durations = fake_task_durations # type: ignore[method-assign] - client._v1 = _FakeV1Ids([]) # type: ignore[assignment] - - result = await client.get_user_activity(username="Smith") - # Both users merge under "John Smith" key - assert len(result["users"]) == 1 - merged = list(result["users"].values()) - assert set(merged[0]["user_ids"]) == {1, 2} - await client.close() diff --git a/glpi_python_client/clients/tests/test_async_selfcall_guard.py b/glpi_python_client/clients/tests/test_async_selfcall_guard.py deleted file mode 100644 index 42fa6a2..0000000 --- a/glpi_python_client/clients/tests/test_async_selfcall_guard.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Structural guard against the async bridge's self-call trap. - -``AsyncBridge`` wraps every public sync method into a coroutine. A sync -body running inside a worker thread that calls a sibling *public* method -through ``self`` therefore receives a coroutine object, not data: the call -is silently dropped (``RuntimeWarning: coroutine ... was never awaited``). - -Any public method that transitively reaches a public method through -``self`` must be given a hand-written async override on -``AsyncGlpiClient``. This test enforces that rule so the bug class cannot -be reintroduced by a future endpoint. -""" - -from __future__ import annotations - -import ast -import inspect -import textwrap - -from glpi_python_client import AsyncGlpiClient, GlpiClient - -# Lifecycle helpers differ between the surfaces on purpose. -_EXCLUDED = {"from_env", "close"} - - -def _public_names(cls: type) -> set[str]: - """Return the public callable names exposed by ``cls``.""" - - return { - name - for name, _ in inspect.getmembers(cls, predicate=callable) - if not name.startswith("_") and name not in _EXCLUDED - } - - -def _self_call_map(cls: type) -> dict[str, set[str]]: - """Map every method of ``cls`` to the ``self.X()`` names it calls.""" - - out: dict[str, set[str]] = {} - for klass in cls.__mro__: - if klass is object: - continue - for name, member in vars(klass).items(): - if name in out: - continue - func = member - if isinstance(func, (classmethod, staticmethod)): - func = func.__func__ - if not inspect.isfunction(func): - continue - try: - tree = ast.parse(textwrap.dedent(inspect.getsource(func))) - except (OSError, TypeError, SyntaxError): # pragma: no cover - continue - calls: set[str] = set() - for node in ast.walk(tree): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "self" - ): - calls.add(node.func.attr) - out[name] = calls - return out - - -def _reaches_public( - name: str, - call_map: dict[str, set[str]], - public: set[str], - seen: set[str] | None = None, -) -> bool: - """Return whether ``name`` reaches a public method through ``self``. - - Recurses through private helpers, which is what catches - ``create_kb_article`` -> ``_apply_category_fallback`` -> - ``set_kb_article_categories``. - """ - - if seen is None: - seen = set() - if name in seen: - return False - seen.add(name) - for callee in call_map.get(name, set()): - if callee in public: - return True - if callee.startswith("_") and _reaches_public(callee, call_map, public, seen): - return True - return False - - -def _is_real_async_override(member: object) -> bool: - """Return whether ``member`` is a hand-written async override. - - The bridge builds its wrappers with ``functools.wraps``, so a - bridge-generated coroutine carries ``__wrapped__`` while a real - override does not. - """ - - if not (inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction(member)): - return False - return not hasattr(member, "__wrapped__") - - -def _offenders() -> list[str]: - """Return public methods that self-call but lack an async override.""" - - public = _public_names(GlpiClient) - call_map = _self_call_map(GlpiClient) - return sorted( - name - for name in public - if _reaches_public(name, call_map, public) - and not _is_real_async_override(getattr(AsyncGlpiClient, name)) - ) - - -def test_no_new_self_call_offenders() -> None: - """No public method may self-call without a hand-written async override.""" - - assert _offenders() == [], ( - f"These public methods call a public method through self with no async " - f"override, so AsyncGlpiClient will silently drop those calls: " - f"{_offenders()}. Add an async override mixin (see " - "clients/custom/_statistics_async.py) and register it in " - "clients/async_client.py before the sync mixin." - ) - - -def test_guard_detects_the_covered_methods() -> None: - """The guard must recognise the existing overrides as valid. - - Without this, a guard that classified every method as covered would - pass ``test_no_new_self_call_offenders`` vacuously. - """ - - for name in ("get_ticket_context", "get_task_statistics", "iter_search_tickets"): - assert _is_real_async_override(getattr(AsyncGlpiClient, name)), ( - f"{name} should be a hand-written async override" - ) - assert not _is_real_async_override(AsyncGlpiClient.get_ticket), ( - "get_ticket should be bridge-generated, not a hand-written override" - ) - - -def test_reaches_public_detects_transitive_and_direct_self_calls() -> None: - """Pin ``_reaches_public`` against a synthetic call map. - - ``test_no_new_self_call_offenders`` now asserts ``_offenders() == []``. - That assertion passes both when every real offender has an async - override *and* when ``_reaches_public`` has regressed to returning - ``False`` for everything — the two cases are indistinguishable from the - assertion alone. The previous non-empty ``_KNOWN_UNCOVERED`` constant - protected against that regression by accident, since a broken detector - would drop the known offenders out of ``_offenders()`` and fail the - equality check; emptying it removed that safety net. - - This test replaces it with a direct, synthetic check of the detection - logic itself, independent of whatever offenders exist on the real - client today. It fixes a call map shaped like the historical - ``create_kb_article`` bug — a public method reaching another public - method only transitively, through a private helper — and a call map - that never reaches anything public, and asserts ``_reaches_public`` - still tells them apart. - """ - - public = {"create_thing", "set_thing_categories", "isolated_public"} - call_map: dict[str, set[str]] = { - # Mirrors create_kb_article -> _apply_category_fallback -> - # set_kb_article_categories: the public caller only reaches the - # public callee transitively, through a private helper. - "create_thing": {"_apply_fallback"}, - "_apply_fallback": {"set_thing_categories"}, - "set_thing_categories": set(), - # A public method that only ever touches private helpers which - # themselves reach nothing public must not be flagged. - "isolated_public": {"_do_local_work"}, - "_do_local_work": {"_do_more_local_work"}, - "_do_more_local_work": set(), - } - - assert _reaches_public("create_thing", call_map, public) is True, ( - "a public method reaching a public method through a private helper " - "must be detected" - ) - assert _reaches_public("isolated_public", call_map, public) is False, ( - "a public method whose private helpers reach nothing public must not be flagged" - ) - - -def test_reaches_public_fires_on_real_create_kb_article_shape() -> None: - """Pin all three ``_offenders`` helpers together against the real client. - - ``test_reaches_public_detects_transitive_and_direct_self_calls`` only - proves ``_reaches_public`` is correct against a hand-built ``public`` - set and ``call_map``. It cannot notice a regression in the other two - helpers ``_offenders`` depends on: if ``_public_names(GlpiClient)`` - returned an empty set, or ``_self_call_map(GlpiClient)`` returned an - empty dict, ``_offenders()`` would be ``[]`` regardless of what - ``_reaches_public`` does, and ``test_no_new_self_call_offenders`` would - pass vacuously. ``_self_call_map`` is especially fragile here: it - silently ``continue``s past ``OSError``/``TypeError``/``SyntaxError`` - raised by ``inspect.getsource``, so a source-less install (e.g. a - zipapp or a stripped wheel) would empty the map and green the guard - without detecting anything. - - ``create_kb_article`` is a permanently-valid end-to-end canary for - this: ``glpi_python_client/clients/api/knowledgebase/_article.py`` is - untouched by the async-bridge fix and still has the exact shape that - caused the original bug — public ``create_kb_article`` reaches public - ``set_kb_article_categories`` only transitively, through the private - ``_apply_category_fallback``. Running the three real helpers together - against ``GlpiClient`` and asserting the detector still fires proves - that ``create_kb_article`` is absent from ``_offenders()`` today - because ``AsyncKBArticleMixin`` (in - :mod:`glpi_python_client.clients.api.knowledgebase._article_async`) - now supplies a real async override, not because the detector went - blind. This is the exact protection the old (now-removed) - ``_KNOWN_UNCOVERED`` constant used to provide by accident. - """ - - assert _reaches_public( - "create_kb_article", _self_call_map(GlpiClient), _public_names(GlpiClient) - ), "the detector must still fire on the real create_kb_article shape" diff --git a/glpi_python_client/clients/tests/test_async_smoke.py b/glpi_python_client/clients/tests/test_async_smoke.py deleted file mode 100644 index b605091..0000000 --- a/glpi_python_client/clients/tests/test_async_smoke.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Smoke tests for the asynchronous client and its async bridge. - -The tests exercise a few representative endpoint methods on -:class:`~glpi_python_client.AsyncGlpiClient` to confirm the bridge -correctly: - -* wraps inherited sync methods into awaitable coroutines, -* dispatches the blocking call off the event loop, and -* preserves the synchronous transport call signatures so test recorders - can stub the same hooks as the sync test suite. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from glpi_python_client import ( - AsyncGlpiClient, - PostFollowup, - PostTicket, - PostUser, -) -from glpi_python_client.testing.utils import FakeResponse, make_async_client - - -class _AsyncRecorder: - """Synchronous transport recorder installed on the async client. - - The recorder relies on the fact that the async bridge wraps the - inherited synchronous transport hooks; the underlying ``_get_*``, - ``_post_*``, ``_update_*`` and ``_delete_*`` helpers themselves - remain synchronous and run inside the bridge worker thread. - """ - - def __init__(self) -> None: - self.calls: list[dict[str, Any]] = [] - - def install(self, client: AsyncGlpiClient) -> None: - """Replace the transport methods on ``client`` with sync stubs.""" - - def _get( - endpoint: str, - params: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "GET", - "endpoint": endpoint, - "params": params, - "skip_entity": skip_entity, - } - ) - return FakeResponse( - status_code=200, payload=[{"id": 1, "name": "n", "content": "c"}] - ) - - def _post( - endpoint: str, - json_body: dict[str, Any] | None = None, - skip_entity: bool = False, - ) -> FakeResponse: - self.calls.append( - { - "method": "POST", - "endpoint": endpoint, - "json": json_body, - "skip_entity": skip_entity, - } - ) - return FakeResponse(status_code=201, payload={"id": 999}) - - client._get_request = _get # type: ignore[method-assign] - client._post_request = _post # type: ignore[method-assign] - - -@pytest.fixture -def async_client() -> AsyncGlpiClient: - """Return one in-memory async client without any real HTTP plumbing.""" - - return make_async_client() - - -@pytest.fixture -def async_recorder(async_client: AsyncGlpiClient) -> _AsyncRecorder: - """Return one transport recorder already wired onto ``async_client``.""" - - rec = _AsyncRecorder() - rec.install(async_client) - return rec - - -async def test_async_create_user_returns_awaitable( - async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder -) -> None: - """The async ``create_user`` returns an awaitable that resolves to the id.""" - - user_id = await async_client.create_user(PostUser(username="alice")) - assert user_id == 999 - assert async_recorder.calls[0]["endpoint"] == "Administration/User" - - -async def test_async_search_tickets_returns_models( - async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder -) -> None: - """The async ``search_tickets`` returns validated ticket models.""" - - tickets = await async_client.search_tickets("status==1") - assert len(tickets) == 1 - assert async_recorder.calls[0]["endpoint"] == "Assistance/Ticket" - - -async def test_async_create_ticket_followup_targets_timeline_endpoint( - async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder -) -> None: - """The async followup helper still hits the timeline endpoint.""" - - await async_client.create_ticket_followup(7, PostFollowup(content="

hi

")) - assert ( - async_recorder.calls[0]["endpoint"] == "Assistance/Ticket/7/Timeline/Followup" - ) - - -async def test_async_create_ticket_serialises_enums( - async_client: AsyncGlpiClient, async_recorder: _AsyncRecorder -) -> None: - """The async create_ticket serialises enums identically to the sync surface.""" - - await async_client.create_ticket(PostTicket(name="t", content="

c

")) - assert async_recorder.calls[0]["endpoint"] == "Assistance/Ticket" - assert async_recorder.calls[0]["json"]["name"] == "t" - - -async def test_async_close_is_idempotent() -> None: - """Calling ``close`` twice on the async client does not raise.""" - - client = make_async_client() - await client.close() - await client.close() - - -async def test_async_context_manager_closes_client() -> None: - """The async context manager closes the client on exit.""" - - async with make_async_client() as client: - assert client.glpi_api_url.endswith("/api.php") - with pytest.raises(RuntimeError, match="closed"): - client._ensure_open() diff --git a/glpi_python_client/clients/tests/test_parity.py b/glpi_python_client/clients/tests/test_parity.py deleted file mode 100644 index 0e91c29..0000000 --- a/glpi_python_client/clients/tests/test_parity.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Parity tests asserting that the sync and async clients expose the same surface. - -These tests guarantee that any public method added to the sync mixins is -automatically reflected on the async client through -:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` -without requiring a parallel async implementation, and conversely that -the async client does not gain methods the sync client lacks. -""" - -from __future__ import annotations - -import inspect - -from glpi_python_client import AsyncGlpiClient, GlpiClient - - -def _public_callable_names(cls: type) -> set[str]: - """Return the public method names exposed by ``cls``. - - Lifecycle helpers that intentionally differ between the sync and - async surfaces are filtered out. - """ - - excluded = {"from_env", "close"} - return { - name - for name, member in inspect.getmembers(cls, predicate=callable) - if not name.startswith("_") and name not in excluded - } - - -def test_sync_and_async_clients_expose_the_same_public_methods() -> None: - """The async client must expose exactly the same endpoint methods.""" - - sync_names = _public_callable_names(GlpiClient) - async_names = _public_callable_names(AsyncGlpiClient) - assert sync_names == async_names - - -def test_sync_endpoint_methods_are_not_coroutine_functions() -> None: - """Every public sync method is a plain or generator function, not a coroutine.""" - - for name in _public_callable_names(GlpiClient): - member = getattr(GlpiClient, name) - assert not inspect.iscoroutinefunction(member), ( - f"GlpiClient.{name} should be synchronous" - ) - assert not inspect.isasyncgenfunction(member), ( - f"GlpiClient.{name} should be synchronous" - ) - - -def test_async_endpoint_methods_are_coroutine_functions() -> None: - """Every public async method is a coroutine or async generator function.""" - - for name in _public_callable_names(AsyncGlpiClient): - member = getattr(AsyncGlpiClient, name) - is_async = inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction( - member - ) - assert is_async, ( - f"AsyncGlpiClient.{name} should be a coroutine or async generator" - ) - - -def test_async_client_close_is_coroutine_and_sync_is_not() -> None: - """Lifecycle helpers differ on purpose between the two surfaces.""" - - assert not inspect.iscoroutinefunction(GlpiClient.close) - assert inspect.iscoroutinefunction(AsyncGlpiClient.close) diff --git a/glpi_python_client/models/api_schema/_content.py b/glpi_python_client/models/api_schema/_content.py index 2c6448d..6110af4 100644 --- a/glpi_python_client/models/api_schema/_content.py +++ b/glpi_python_client/models/api_schema/_content.py @@ -12,7 +12,7 @@ to canonical Markdown before being assigned to the field, so attribute access always returns Markdown. * On serialisation (outgoing request bodies built via - :func:`glpi_python_client.clients.commons._payloads.model_to_payload`) + :func:`glpi_python_client._sync.clients.commons._payloads.model_to_payload`) the Markdown value is rendered back to HTML so GLPI receives the format it expects. diff --git a/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py b/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py index b6aef40..b05e65d 100644 --- a/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py +++ b/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py @@ -11,7 +11,7 @@ import pytest -from glpi_python_client.clients.commons._payloads import model_to_payload +from glpi_python_client._sync.clients.commons._payloads import model_to_payload from glpi_python_client.models.api_schema.assistance import ( GetTicket, PatchTicket, diff --git a/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py b/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py index 6e92231..8e68e5c 100644 --- a/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py +++ b/glpi_python_client/models/api_schema/knowledgebase/tests/test_article_schema.py @@ -2,7 +2,7 @@ from __future__ import annotations -from glpi_python_client.clients.commons._payloads import model_to_payload +from glpi_python_client._sync.clients.commons._payloads import model_to_payload from glpi_python_client.models.api_schema._common import IdNameRef from glpi_python_client.models.api_schema.knowledgebase import ( DeleteKBArticle, diff --git a/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py b/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py index ee6f144..3aaa20e 100644 --- a/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py +++ b/glpi_python_client/models/api_schema/knowledgebase/tests/test_comment_schema.py @@ -2,7 +2,7 @@ from __future__ import annotations -from glpi_python_client.clients.commons._payloads import model_to_payload +from glpi_python_client._sync.clients.commons._payloads import model_to_payload from glpi_python_client.models.api_schema._common import IdNameRef from glpi_python_client.models.api_schema.knowledgebase import ( DeleteKBArticleComment, diff --git a/glpi_python_client/clients/api/knowledgebase/tests/__init__.py b/glpi_python_client/tests/api_knowledgebase/__init__.py similarity index 100% rename from glpi_python_client/clients/api/knowledgebase/tests/__init__.py rename to glpi_python_client/tests/api_knowledgebase/__init__.py diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_article_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_article_mixin.py similarity index 100% rename from glpi_python_client/clients/api/knowledgebase/tests/test_article_mixin.py rename to glpi_python_client/tests/api_knowledgebase/test_article_mixin.py diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_category_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_category_mixin.py similarity index 100% rename from glpi_python_client/clients/api/knowledgebase/tests/test_category_mixin.py rename to glpi_python_client/tests/api_knowledgebase/test_category_mixin.py diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_comment_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py similarity index 100% rename from glpi_python_client/clients/api/knowledgebase/tests/test_comment_mixin.py rename to glpi_python_client/tests/api_knowledgebase/test_comment_mixin.py diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_kb_failure_paths.py b/glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py similarity index 100% rename from glpi_python_client/clients/api/knowledgebase/tests/test_kb_failure_paths.py rename to glpi_python_client/tests/api_knowledgebase/test_kb_failure_paths.py diff --git a/glpi_python_client/clients/api/knowledgebase/tests/test_revision_mixin.py b/glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py similarity index 100% rename from glpi_python_client/clients/api/knowledgebase/tests/test_revision_mixin.py rename to glpi_python_client/tests/api_knowledgebase/test_revision_mixin.py diff --git a/glpi_python_client/clients/api/plugins/tests/__init__.py b/glpi_python_client/tests/api_plugins/__init__.py similarity index 100% rename from glpi_python_client/clients/api/plugins/tests/__init__.py rename to glpi_python_client/tests/api_plugins/__init__.py diff --git a/glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py b/glpi_python_client/tests/api_plugins/test_fields_mixin.py similarity index 98% rename from glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py rename to glpi_python_client/tests/api_plugins/test_fields_mixin.py index fce847a..5390152 100644 --- a/glpi_python_client/clients/api/plugins/tests/test_fields_mixin.py +++ b/glpi_python_client/tests/api_plugins/test_fields_mixin.py @@ -7,7 +7,7 @@ import pytest from glpi_python_client import GlpiClient, GlpiProtocolError, GlpiValidationError -from glpi_python_client.clients.api.plugins._fields import ( +from glpi_python_client._sync.clients.api.plugins._fields import ( _container_targets_itemtype, _extract_row_id, _value_itemtype_for, @@ -364,7 +364,7 @@ def test_set_ticket_custom_fields_rejects_container_without_id( """A matched container with no ``id`` raises before any write. The container came from the server's own - :meth:`~glpi_python_client.clients.api.plugins._fields.PluginFieldsMixin.list_plugin_fields_containers` + :meth:`~glpi_python_client._sync.clients.api.plugins._fields.PluginFieldsMixin.list_plugin_fields_containers` response, so a missing ``id`` is a server-side contract violation, not a caller mistake: ``GlpiProtocolError``. It still inherits ``ValueError`` so existing callers that catch the broader type keep diff --git a/glpi_python_client/tests/auth/__init__.py b/glpi_python_client/tests/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/glpi_python_client/auth/tests/test_auth.py b/glpi_python_client/tests/auth/test_auth.py similarity index 99% rename from glpi_python_client/auth/tests/test_auth.py rename to glpi_python_client/tests/auth/test_auth.py index a4b62af..8bed08b 100644 --- a/glpi_python_client/auth/tests/test_auth.py +++ b/glpi_python_client/tests/auth/test_auth.py @@ -13,7 +13,7 @@ GlpiTransportError, GlpiValidationError, ) -from glpi_python_client.auth.auth import GLPITokenManager +from glpi_python_client._sync.auth.auth import GLPITokenManager from glpi_python_client.testing.utils import FakeResponse, TokenResponse diff --git a/glpi_python_client/auth/tests/test_v1_session.py b/glpi_python_client/tests/auth/test_v1_session.py similarity index 99% rename from glpi_python_client/auth/tests/test_v1_session.py rename to glpi_python_client/tests/auth/test_v1_session.py index b51d00e..f5de54b 100644 --- a/glpi_python_client/auth/tests/test_v1_session.py +++ b/glpi_python_client/tests/auth/test_v1_session.py @@ -14,7 +14,7 @@ GlpiTransportError, GlpiValidationError, ) -from glpi_python_client.auth._v1_session import GLPIV1Session +from glpi_python_client._sync.auth._v1_session import GLPIV1Session from glpi_python_client.testing.utils import FakeResponse diff --git a/glpi_python_client/clients/tests/__init__.py b/glpi_python_client/tests/clients/__init__.py similarity index 100% rename from glpi_python_client/clients/tests/__init__.py rename to glpi_python_client/tests/clients/__init__.py diff --git a/glpi_python_client/clients/tests/test_api_coverage.py b/glpi_python_client/tests/clients/test_api_coverage.py similarity index 100% rename from glpi_python_client/clients/tests/test_api_coverage.py rename to glpi_python_client/tests/clients/test_api_coverage.py diff --git a/glpi_python_client/clients/tests/test_glpi_client.py b/glpi_python_client/tests/clients/test_glpi_client.py similarity index 89% rename from glpi_python_client/clients/tests/test_glpi_client.py rename to glpi_python_client/tests/clients/test_glpi_client.py index cc1dad0..8ca2d60 100644 --- a/glpi_python_client/clients/tests/test_glpi_client.py +++ b/glpi_python_client/tests/clients/test_glpi_client.py @@ -8,7 +8,7 @@ import pytest from glpi_python_client import GlpiClient, GlpiValidationError -from glpi_python_client.clients.commons._config import ( +from glpi_python_client._sync.clients.commons._config import ( build_client_env_config, normalize_client_api_url, parse_optional_env_bool, @@ -267,28 +267,37 @@ def test_async_transport_ensure_open_blocks_after_close() -> None: client._ensure_open() -def test_glpi_client_init_failure_closes_session( +def test_glpi_client_init_failure_creates_no_session( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A failing token-manager init releases the underlying session.""" + """A bad credential set is rejected before any session is constructed. - closed: dict[str, Any] = {} + This used to build the session first and unwind it from an ``except`` + clause. That shape is not expressible on the async surface -- an + ``httpx.AsyncClient`` has no synchronous close and a constructor cannot + await one -- so the configuration is now validated up front instead. + + Asserting *nothing was built* is the stronger property: there is no + window in which a session exists but the client does not, so there is + nothing that can leak if the unwind is ever missed. + """ import httpx - original_close = httpx.Client.close + constructed: list[object] = [] + original_init = httpx.Client.__init__ - def _track_close(self: httpx.Client) -> None: - closed["closed"] = True - original_close(self) + def _track_init(self: httpx.Client, *args: Any, **kwargs: Any) -> None: + constructed.append(self) + original_init(self, *args, **kwargs) - monkeypatch.setattr(httpx.Client, "close", _track_close) + monkeypatch.setattr(httpx.Client, "__init__", _track_init) with pytest.raises(ValueError): GlpiClient( glpi_api_url="https://glpi.example.test/api.php/v2", client_id="only-id-no-secret", ) - assert closed.get("closed") is True + assert constructed == [], "a transport session was built for a rejected config" def test_no_other_vars_leak_into_environ_test() -> None: diff --git a/glpi_python_client/clients/tests/test_method_invocation.py b/glpi_python_client/tests/clients/test_method_invocation.py similarity index 81% rename from glpi_python_client/clients/tests/test_method_invocation.py rename to glpi_python_client/tests/clients/test_method_invocation.py index 49dc545..1fecef7 100644 --- a/glpi_python_client/clients/tests/test_method_invocation.py +++ b/glpi_python_client/tests/clients/test_method_invocation.py @@ -89,6 +89,12 @@ def json(self) -> Any: return self._payload +def _stub_v1_payload() -> Any: + """Both shapes the v1 callers expect from a JSON response.""" + + return [{"id": 1, "name": "stub", "itemtypes": '["Ticket"]'}] + + class _StubV1: """Stand-in for the legacy v1 session, logging into the shared call log.""" @@ -97,9 +103,7 @@ def __init__(self, calls: list[str]) -> None: def request_json(self, method: str, path: str, **kwargs: Any) -> Any: self._calls.append(f"v1 {method} {path}") - # Both shapes the v1 callers expect: a list for collection reads and - # a dict carrying an id for writes. - return [{"id": 1, "name": "stub", "itemtypes": '["Ticket"]'}] + return _stub_v1_payload() def upload_document(self, *args: Any, **kwargs: Any) -> int: self._calls.append("v1 POST Document") @@ -109,23 +113,60 @@ def close(self) -> None: """No-op; the real session is closed with the client.""" +class _AsyncStubV1(_StubV1): + """Async twin of :class:`_StubV1`. + + The async client awaits every v1 call, so the stub's methods have to be + coroutines. Returning a plain value would surface as ``TypeError: object + NoneType can't be used in 'await' expression`` -- a failure about the + stub, not about the client. + """ + + async def request_json(self, method: str, path: str, **kwargs: Any) -> Any: + self._calls.append(f"v1 {method} {path}") + return _stub_v1_payload() + + async def upload_document(self, *args: Any, **kwargs: Any) -> int: + self._calls.append("v1 POST Document") + return 1 + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def _stub_response_for(method: str, url: str) -> _StubResponse: + """Build the response shape the endpoint at ``url`` expects.""" + + # List endpoints must see a list; single-item endpoints an object. + # GLPI collection paths are the ones the client pages over, and they + # are exactly those called without a trailing numeric id. + tail = url.rstrip("/").rsplit("/", 1)[-1] + payload: Any = _payload_for(url) + if not tail.isdigit() and method.upper() == "GET": + payload = [payload] + return _StubResponse(payload) + + def _install_stub(client: GlpiClient | AsyncGlpiClient) -> list[str]: - """Route every HTTP call to an in-memory stub; return the call log.""" + """Route every HTTP call to an in-memory stub; return the call log. + + The stub is installed at the ``session.request`` seam on both surfaces, + so URL building, headers, parameter normalisation, response validation + and model parsing all execute for real -- only the socket is replaced. + """ calls: list[str] = [] + is_async = isinstance(client, AsyncGlpiClient) def _request(method: str, url: str, **kwargs: Any) -> _StubResponse: calls.append(f"{method} {url}") - # List endpoints must see a list; single-item endpoints an object. - # GLPI collection paths are the ones the client pages over, and they - # are exactly those called without a trailing numeric id. - tail = url.rstrip("/").rsplit("/", 1)[-1] - payload: Any = _payload_for(url) - if not tail.isdigit() and method.upper() == "GET": - payload = [payload] - return _StubResponse(payload) - - client._session.request = _request # type: ignore[method-assign,union-attr] + return _stub_response_for(method, url) + + async def _arequest(method: str, url: str, **kwargs: Any) -> _StubResponse: + calls.append(f"{method} {url}") + return _stub_response_for(method, url) + + client._session.request = _arequest if is_async else _request # type: ignore[method-assign,union-attr,assignment] # Pretend a valid, non-expiring token is already held so no OAuth round # trip happens and the call log contains only endpoint traffic. client._auth.access_token = "stub-token" @@ -133,7 +174,7 @@ def _request(method: str, url: str, **kwargs: Any) -> _StubResponse: # Several features (plugin fields, KB category writes, document upload, # actor statistics) run on the legacy v1 session rather than the v2 # transport. Stub it into the same log so they are exercised too. - client._v1 = _StubV1(calls) # type: ignore[assignment] + client._v1 = (_AsyncStubV1 if is_async else _StubV1)(calls) # type: ignore[assignment] return calls diff --git a/glpi_python_client/clients/tests/test_raise_site_audit.py b/glpi_python_client/tests/clients/test_raise_site_audit.py similarity index 96% rename from glpi_python_client/clients/tests/test_raise_site_audit.py rename to glpi_python_client/tests/clients/test_raise_site_audit.py index fe75528..a0be8a3 100644 --- a/glpi_python_client/clients/tests/test_raise_site_audit.py +++ b/glpi_python_client/tests/clients/test_raise_site_audit.py @@ -79,13 +79,14 @@ def test_the_ast_walk_finds_a_known_raise_site() -> None: sites = _raise_sites() assert sites, "the raise-site walk found nothing -- it is broken" - # clients/commons/_transport.py:106 raises a deliberately-exempt + # clients/commons/_transport.py raises a deliberately-exempt # RuntimeError (decision D3) that this migration never touches, making # it a stable landmark to confirm the walk actually inspects source. transport_sites = [ site for site in sites - if site[0] == "clients/commons/_transport.py" and site[2] == "RuntimeError" + if site[0].endswith("clients/commons/_transport.py") + and site[2] == "RuntimeError" ] assert transport_sites, ( "the raise-site walk did not find the known RuntimeError raise in " diff --git a/glpi_python_client/clients/tests/test_smoke.py b/glpi_python_client/tests/clients/test_smoke.py similarity index 100% rename from glpi_python_client/clients/tests/test_smoke.py rename to glpi_python_client/tests/clients/test_smoke.py diff --git a/glpi_python_client/clients/commons/tests/__init__.py b/glpi_python_client/tests/commons/__init__.py similarity index 63% rename from glpi_python_client/clients/commons/tests/__init__.py rename to glpi_python_client/tests/commons/__init__.py index 0bcd31e..df68862 100644 --- a/glpi_python_client/clients/commons/tests/__init__.py +++ b/glpi_python_client/tests/commons/__init__.py @@ -1,4 +1,4 @@ -"""Tests for :mod:`glpi_python_client.clients.commons` helpers. +"""Tests for :mod:`glpi_python_client._sync.clients.commons` helpers. These tests exercise the small contract-aligned helpers shared by the API mixins without dispatching real HTTP calls. diff --git a/glpi_python_client/clients/commons/tests/test_constants.py b/glpi_python_client/tests/commons/test_constants.py similarity index 88% rename from glpi_python_client/clients/commons/tests/test_constants.py rename to glpi_python_client/tests/commons/test_constants.py index 9732202..7e337c3 100644 --- a/glpi_python_client/clients/commons/tests/test_constants.py +++ b/glpi_python_client/tests/commons/test_constants.py @@ -2,7 +2,7 @@ from __future__ import annotations -from glpi_python_client.clients.commons import _constants +from glpi_python_client._sync.clients.commons import _constants def test_knowledgebase_endpoint_constants() -> None: diff --git a/glpi_python_client/clients/commons/tests/test_filters.py b/glpi_python_client/tests/commons/test_filters.py similarity index 94% rename from glpi_python_client/clients/commons/tests/test_filters.py rename to glpi_python_client/tests/commons/test_filters.py index fcef3b6..0f77153 100644 --- a/glpi_python_client/clients/commons/tests/test_filters.py +++ b/glpi_python_client/tests/commons/test_filters.py @@ -1,8 +1,8 @@ -"""Unit tests for :mod:`glpi_python_client.clients.commons._filters`.""" +"""Unit tests for :mod:`glpi_python_client._sync.clients.commons._filters`.""" from __future__ import annotations -from glpi_python_client.clients.commons._filters import ( +from glpi_python_client._sync.clients.commons._filters import ( escape_rsql_like_value, escape_rsql_text_value, rsql_all_filter, diff --git a/glpi_python_client/clients/commons/tests/test_http.py b/glpi_python_client/tests/commons/test_http.py similarity index 97% rename from glpi_python_client/clients/commons/tests/test_http.py rename to glpi_python_client/tests/commons/test_http.py index bd1f6e1..0774e65 100644 --- a/glpi_python_client/clients/commons/tests/test_http.py +++ b/glpi_python_client/tests/commons/test_http.py @@ -1,4 +1,4 @@ -"""Unit tests for :mod:`glpi_python_client.clients.commons._http`. +"""Unit tests for :mod:`glpi_python_client._sync.clients.commons._http`. The tests cover the small request and response helper utilities used by the asynchronous transport and the per-endpoint mixins. @@ -11,7 +11,7 @@ import pytest from glpi_python_client import GlpiProtocolError, GlpiValidationError -from glpi_python_client.clients.commons._http import ( +from glpi_python_client._sync.clients.commons._http import ( build_request_headers, build_request_url, ensure_response_status, diff --git a/glpi_python_client/clients/commons/tests/test_payloads.py b/glpi_python_client/tests/commons/test_payloads.py similarity index 93% rename from glpi_python_client/clients/commons/tests/test_payloads.py rename to glpi_python_client/tests/commons/test_payloads.py index 047f497..443dbc2 100644 --- a/glpi_python_client/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/tests/commons/test_payloads.py @@ -1,8 +1,8 @@ -"""Unit tests for :mod:`glpi_python_client.clients.commons._payloads`.""" +"""Unit tests for :mod:`glpi_python_client._sync.clients.commons._payloads`.""" from __future__ import annotations -from glpi_python_client.clients.commons._payloads import ( +from glpi_python_client._sync.clients.commons._payloads import ( model_from_payload, model_to_payload, ) diff --git a/glpi_python_client/clients/commons/tests/test_retry_semantics.py b/glpi_python_client/tests/commons/test_retry_semantics.py similarity index 98% rename from glpi_python_client/clients/commons/tests/test_retry_semantics.py rename to glpi_python_client/tests/commons/test_retry_semantics.py index c8c9d97..dff5db6 100644 --- a/glpi_python_client/clients/commons/tests/test_retry_semantics.py +++ b/glpi_python_client/tests/commons/test_retry_semantics.py @@ -22,7 +22,7 @@ GlpiTimeoutError, GlpiTransportError, ) -from glpi_python_client.clients.commons._http import ensure_response_status +from glpi_python_client._sync.clients.commons._http import ensure_response_status from glpi_python_client.testing.utils import FakeResponse, make_client _RETRIED_METHODS = ( diff --git a/glpi_python_client/clients/commons/tests/test_transport.py b/glpi_python_client/tests/commons/test_transport.py similarity index 100% rename from glpi_python_client/clients/commons/tests/test_transport.py rename to glpi_python_client/tests/commons/test_transport.py diff --git a/glpi_python_client/tests/custom/__init__.py b/glpi_python_client/tests/custom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/glpi_python_client/clients/custom/tests/test_statistics.py b/glpi_python_client/tests/custom/test_statistics.py similarity index 99% rename from glpi_python_client/clients/custom/tests/test_statistics.py rename to glpi_python_client/tests/custom/test_statistics.py index ebb187b..9d3037e 100644 --- a/glpi_python_client/clients/custom/tests/test_statistics.py +++ b/glpi_python_client/tests/custom/test_statistics.py @@ -19,7 +19,7 @@ GlpiTicketType, GlpiValidationError, ) -from glpi_python_client.clients.custom._statistics import ( +from glpi_python_client._sync.clients.custom._statistics import ( _V1_SO_ASSIGNEE, _V1_SO_REQUESTER, ) diff --git a/glpi_python_client/clients/custom/tests/test_ticket_context.py b/glpi_python_client/tests/custom/test_ticket_context.py similarity index 94% rename from glpi_python_client/clients/custom/tests/test_ticket_context.py rename to glpi_python_client/tests/custom/test_ticket_context.py index 5b658c2..cc4694a 100644 --- a/glpi_python_client/clients/custom/tests/test_ticket_context.py +++ b/glpi_python_client/tests/custom/test_ticket_context.py @@ -1,7 +1,7 @@ """Unit tests for the synchronous ticket-context mixin. The tests stub ``_get_request`` on a real :class:`GlpiClient` so -:meth:`~glpi_python_client.clients.custom._ticket_context.TicketContextMixin.get_ticket_context` +:meth:`~glpi_python_client._sync.clients.custom._ticket_context.TicketContextMixin.get_ticket_context` exercises its real aggregation logic without any network call. """ diff --git a/glpi_python_client/tests/test_async_surface.py b/glpi_python_client/tests/test_async_surface.py new file mode 100644 index 0000000..04f65ed --- /dev/null +++ b/glpi_python_client/tests/test_async_surface.py @@ -0,0 +1,231 @@ +"""Runtime tests for the hand-written async surface. + +The bulk of the suite exercises the generated sync client, which is a 1:1 +token transform of this tree and therefore covers the shared logic. What it +cannot cover is anything that only exists once ``async``/``await`` are real: +whether the client actually awaits, whether concurrent tasks can contend the +auth lock without deadlocking, and whether the fan-out helper really runs +work concurrently rather than in sequence. + +Those are exactly the properties the codegen cannot verify -- the diff gate +proves the two trees *correspond*, not that the async one *works* -- so they +are tested here directly. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx +import pytest + +from glpi_python_client import AsyncGlpiClient, GlpiTimeoutError, GlpiTransportError +from glpi_python_client._async._concurrency import gather +from glpi_python_client.testing.utils import make_async_client + + +class _Response: + """Minimal response object covering what the transport layer reads.""" + + def __init__(self, payload: Any, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.headers: dict[str, str] = {} + self.text = "{}" + self.reason = "OK" + self.url = "https://glpi.example.test/api.php/v2/stub" + self.content = b"{}" + + def json(self) -> Any: + return self._payload + + +def _stub(client: AsyncGlpiClient, payload: Any) -> list[str]: + """Install an async transport stub and pretend a token is held.""" + + calls: list[str] = [] + + async def _request(method: str, url: str, **kwargs: Any) -> _Response: + calls.append(f"{method} {url}") + return _Response(payload) + + client._session.request = _request # type: ignore[method-assign,assignment] + client._auth.access_token = "stub-token" + client._auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(days=365) + return calls + + +async def test_a_read_awaits_and_returns_a_model() -> None: + """The async client dispatches and parses exactly like its twin.""" + + client = make_async_client() + calls = _stub(client, {"id": 42, "name": "async ticket"}) + try: + ticket = await client.get_ticket(42) + assert ticket.id == 42 + assert ticket.name == "async ticket" + assert calls, "no HTTP call was dispatched" + finally: + await client.close() + + +async def test_close_is_idempotent_and_blocks_further_calls() -> None: + """Closing twice is safe and a closed client refuses to dispatch.""" + + client = make_async_client() + _stub(client, {"id": 1, "name": "x"}) + await client.close() + await client.close() + with pytest.raises(RuntimeError, match="closed"): + await client.get_ticket(1) + + +async def test_the_async_context_manager_closes_on_exit() -> None: + """``async with`` releases the client, so ``__aexit__`` really awaits.""" + + client = make_async_client() + _stub(client, {"id": 1, "name": "x"}) + async with client as entered: + assert entered is client + assert client._closed is True + + +async def test_concurrent_tasks_contend_the_auth_lock_without_deadlocking() -> None: + """Many tasks may refresh the token at once and all of them finish. + + This is the property the hand-written ``_concurrency`` twin exists for. + The lock is held across an ``await``; with a ``threading.Lock`` -- the + right primitive for the *sync* tree -- the second task to arrive would + block the event loop, the holder could never resume to release it, and + this test would hang forever rather than fail. Running real contention + is the only way to observe that. + """ + + client = make_async_client() + calls = _stub(client, {"id": 1, "name": "x"}) + # Force every task through the token-acquisition path. + client._auth.access_token = None + + acquisitions = 0 + + async def _acquire() -> None: + nonlocal acquisitions + acquisitions += 1 + await asyncio.sleep(0) # a real suspension point inside the lock + client._auth.access_token = "stub-token" + + client._auth._acquire_token = _acquire # type: ignore[method-assign] + try: + results = await asyncio.wait_for( + asyncio.gather(*(client.get_ticket(i) for i in range(1, 11))), + timeout=10, + ) + assert len(results) == 10 + assert len(calls) == 10 + finally: + await client.close() + + +async def test_gather_runs_work_concurrently() -> None: + """The async ``gather`` overlaps its arguments rather than serialising. + + The sync twin returns already-evaluated values, which is correct there. + Here the whole point is that the calls overlap. + + Completion *order* is the primary evidence: the shorter sleep finishes + first even though it is passed second, which cannot happen if the two + ran one after the other. The elapsed-time bound is a secondary check, + and the delays are chosen well clear of the platform timer granularity + (~16ms on Windows) so the margin between "concurrent" and "sequential" + is not swallowed by rounding. + """ + + slow_delay, fast_delay = 0.30, 0.15 + order: list[str] = [] + + async def _sleep_then_record(label: str, delay: float) -> str: + await asyncio.sleep(delay) + order.append(label) + return label + + loop = asyncio.get_running_loop() + started = loop.time() + results = await gather( + _sleep_then_record("slow", slow_delay), + _sleep_then_record("fast", fast_delay), + ) + elapsed = loop.time() - started + + # Results keep argument order; completion order is by speed. + assert results == ["slow", "fast"] + assert order == ["fast", "slow"], "the two coroutines did not overlap" + assert elapsed < (slow_delay + fast_delay) * 0.9, ( + f"gather took {elapsed:.3f}s, close to the sequential " + f"{slow_delay + fast_delay:.3f}s -- it serialised its arguments" + ) + + +async def test_network_faults_are_translated_on_the_async_path() -> None: + """A transport fault surfaces as a library error, not an httpx one. + + The sync path has its own test for this. Repeating it here is not + duplication: the translation sits in a ``try``/``except`` around an + awaited call, and an ``except`` clause that fails to cover an awaited + expression is a distinct mistake the sync test cannot detect. + """ + + client = make_async_client() + _stub(client, {}) + + async def _boom(method: str, url: str, **kwargs: Any) -> _Response: + raise httpx.ConnectError("network down") + + client._session.request = _boom # type: ignore[method-assign,assignment] + # Keep the retry from spending 6 real seconds on the way to failing. + client._get_request.retry.wait = lambda *a, **k: 0 # type: ignore[attr-defined] + try: + with pytest.raises(GlpiTransportError) as excinfo: + await client.get_ticket(1) + assert isinstance(excinfo.value.__cause__, httpx.ConnectError) + finally: + await client.close() + + +async def test_timeouts_narrow_on_the_async_path() -> None: + """A timeout narrows to ``GlpiTimeoutError`` when awaited too.""" + + client = make_async_client() + _stub(client, {}) + + async def _slow(method: str, url: str, **kwargs: Any) -> _Response: + raise httpx.ConnectTimeout("too slow") + + client._session.request = _slow # type: ignore[method-assign,assignment] + client._get_request.retry.wait = lambda *a, **k: 0 # type: ignore[attr-defined] + try: + with pytest.raises(GlpiTimeoutError): + await client.get_ticket(1) + finally: + await client.close() + + +async def test_the_paginating_generator_is_an_async_generator() -> None: + """``iter_search_*`` yields pages under ``async for``. + + The generators are the one place the codegen has to get two things + right at once -- ``async def`` plus ``AsyncIterator`` -- and a mistake + in either shows up only when the generator is actually driven. + """ + + client = make_async_client() + _stub(client, [{"id": 1, "name": "one"}]) + try: + pages = [] + async for page in client.iter_search_tickets(batch_size=50): + pages.append(page) + break + assert pages and pages[0][0].id == 1 + finally: + await client.close() diff --git a/glpi_python_client/tests/test_unasync_codegen.py b/glpi_python_client/tests/test_unasync_codegen.py index 7e60db5..5119b98 100644 --- a/glpi_python_client/tests/test_unasync_codegen.py +++ b/glpi_python_client/tests/test_unasync_codegen.py @@ -41,6 +41,10 @@ #: third-party name; if one of those shows up as an identifier this package #: defines, it is a collision and the scan below fails. _INTENTIONAL_RENAMES = { + # unasync's built-in map turns the async context-manager protocol into + # the sync one; defining these is the whole point. + "__aenter__", + "__aexit__", "AsyncGlpiClient", "AsyncClient", "AsyncBaseTransport", diff --git a/pyproject.toml b/pyproject.toml index bfce818..87bdaf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,16 @@ source = ["glpi_python_client"] omit = [ "*/tests/*", "glpi_python_client/testing/*", + # The async tree is the hand-written source; the sync tree is generated + # from it by a 1:1 token transform that only strips async/await. Every + # statement therefore exists in both, so measuring both would count the + # same logic twice and let real gaps hide behind a doubled denominator. + # Coverage is measured on the generated tree, which the suite exercises + # directly, and the correspondence between the two is enforced + # separately: unasync_build.py --check proves the trees match, mypy + # strict checks both, and tests/test_async_surface.py exercises the + # async surface at runtime -- including the paths that exist only there. + "glpi_python_client/_async/*", ] [tool.coverage.report] @@ -133,6 +143,12 @@ exclude_lines = [ [tool.ruff] line-length = 88 target-version = "py310" +# The sync client tree is generated by unasync_build.py from _async/ and +# must stay byte-identical to what regeneration produces -- that identity +# is what the CI gate checks. Formatting it would fight the generator, and +# reformatting the generated output is meaningless anyway: style is +# enforced on the hand-written source it comes from. mypy still checks it. +extend-exclude = ["glpi_python_client/_sync"] [tool.ruff.lint] select = ["B", "E", "F", "I", "RUF", "UP"] diff --git a/unasync_build.py b/unasync_build.py index 9a48021..c0936cc 100644 --- a/unasync_build.py +++ b/unasync_build.py @@ -72,6 +72,10 @@ #: deliberate: every entry is a chance for a silent collision, and the #: shorter the list, the smaller that surface. TOKEN_REPLACEMENTS = { + # Intra-tree imports are absolute, so the package segment itself is a + # NAME token and rewriting it repoints every one of them at the + # generated tree. This is why no module needs relative imports. + "_async": "_sync", "AsyncGlpiClient": "GlpiClient", "AsyncClient": "Client", "AsyncBaseTransport": "BaseTransport", From 0096b549f7f7139ab48a3413f6a01833490c9bb6 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 27 Jul 2026 17:58:38 +0200 Subject: [PATCH 5/9] fix(async): drive async generators with `async for`, not `await` Found by running the async client against live preprod GLPI: three statistics integration tests failed with "object async_generator can't be used in 'await' expression". The async-ification pass had put `await` in front of `self.iter_search_tickets(...)` inside a `for` loop at two sites in _statistics.py. Worth recording why nothing else caught it. `await gen()` and `async for x in gen()` strip to the *same* correct synchronous loop, so the generated sync client was right, the diff gate was clean, mypy passed on both trees, and all 523 unit tests passed. The bug existed only on the async surface, only on paths the unit suite reaches through the sync client. A green suite proved nothing here. Adds a static guard: test_no_async_generator_is_awaited walks the async tree, collects every `async def` that yields, and fails on any `await` of one. Mutation-checked -- reintroducing either site fails the test. Also updates the async integration tests for the removed bridge. The executor-routing test is replaced rather than deleted: it asserted that a caller-supplied pool received the work, and the property that replaced it is that there is no pool at all, so the calls run on the loop thread. That is now checked directly against the live server. Live suite: 42 passed, 4 skipped (Fields plugin absent from preprod). This also settles the open question of whether preprod still accepts the v1 initSession flow after the transport swap -- it does. 524 passed, mypy strict clean (119 files), coverage 96.95%. Co-Authored-By: Claude Opus 5 (1M context) --- .../_async/clients/custom/_statistics.py | 4 +- .../tests/test_unasync_codegen.py | 65 ++++++++++++++++ integration_tests/test_integration_async.py | 77 ++++++++----------- 3 files changed, 97 insertions(+), 49 deletions(-) diff --git a/glpi_python_client/_async/clients/custom/_statistics.py b/glpi_python_client/_async/clients/custom/_statistics.py index b8c752e..e3e075f 100644 --- a/glpi_python_client/_async/clients/custom/_statistics.py +++ b/glpi_python_client/_async/clients/custom/_statistics.py @@ -586,7 +586,7 @@ async def get_task_durations( ticket_ids: list[int] = [] ticket_entity_map: dict[int, str] = {} - for batch in await self.iter_search_tickets( # type: ignore[attr-defined] + async for batch in self.iter_search_tickets( # type: ignore[attr-defined] rsql_filter, batch_size=200, ): @@ -740,7 +740,7 @@ async def get_user_activity( # v2 both walks returned the same unfiltered window. window_filter = rsql_all_filter(date_range, _LIVE_TICKETS) or "" window_ids: set[int] = set() - for batch in await self.iter_search_tickets( # type: ignore[attr-defined] + async for batch in self.iter_search_tickets( # type: ignore[attr-defined] window_filter, batch_size=200, ): diff --git a/glpi_python_client/tests/test_unasync_codegen.py b/glpi_python_client/tests/test_unasync_codegen.py index 5119b98..11279ff 100644 --- a/glpi_python_client/tests/test_unasync_codegen.py +++ b/glpi_python_client/tests/test_unasync_codegen.py @@ -129,6 +129,71 @@ def test_no_identifier_collides_with_a_substitution_key() -> None: ) +def _async_generator_names() -> set[str]: + """Return the name of every ``async def`` in ``_async/`` that yields.""" + + names: set[str] = set() + for path in sorted(_ASYNC_DIR.rglob("*.py")): + if "__pycache__" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.AsyncFunctionDef): + continue + if any( + isinstance(inner, ast.Yield | ast.YieldFrom) for inner in ast.walk(node) + ): + names.add(node.name) + return names + + +def test_no_async_generator_is_awaited() -> None: + """An async generator is driven with ``async for``, never awaited. + + This is a mistake the sync tree cannot reveal. ``await gen()`` and + ``async for x in gen()`` both generate the *same* correct synchronous + loop once ``async``/``await`` are stripped, so the generated client + works, the diff gate is clean, mypy is satisfied on both trees, and + every test that exercises the sync surface passes -- while the async + surface raises ``TypeError: object async_generator can't be used in + 'await' expression`` the moment that code path is reached. + + It was found by running the async client against a live server; this + check makes that failure a static one. + """ + + generators = _async_generator_names() + assert generators, "no async generators found -- this check is vacuous" + + offenders: list[tuple[str, int, str]] = [] + for path in sorted(_ASYNC_DIR.rglob("*.py")): + if "__pycache__" in path.parts: + continue + rel = path.relative_to(_REPO_ROOT).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Await): + continue + call = node.value + if not isinstance(call, ast.Call): + continue + func = call.func + name = ( + func.attr + if isinstance(func, ast.Attribute) + else func.id + if isinstance(func, ast.Name) + else None + ) + if name in generators: + offenders.append((rel, node.lineno, name or "?")) + + assert offenders == [], ( + "these async generators are awaited instead of being driven with " + f"`async for`: {offenders}" + ) + + def test_the_concurrency_twins_expose_the_same_surface() -> None: """Both hand-written ``_concurrency`` twins export the same names. diff --git a/integration_tests/test_integration_async.py b/integration_tests/test_integration_async.py index 39a9d75..9c8c4f7 100644 --- a/integration_tests/test_integration_async.py +++ b/integration_tests/test_integration_async.py @@ -9,7 +9,7 @@ * OAuth token-acquisition lock contention from many coroutines racing for the very first authenticated call. * Routing every call through a caller-supplied - :class:`concurrent.futures.ThreadPoolExecutor`. + real non-blocking I/O on the caller's event loop. * Cancellation of an in-flight awaiting coroutine. * Exception propagation from a worker thread back to the awaiter. @@ -21,7 +21,6 @@ import asyncio from collections.abc import AsyncIterator -from concurrent.futures import ThreadPoolExecutor import pytest import pytest_asyncio @@ -47,11 +46,7 @@ pytestmark = pytest.mark.integration -def _build_async_client( - config: _LiveGlpiConfig, - *, - executor: ThreadPoolExecutor | None = None, -) -> AsyncGlpiClient: +def _build_async_client(config: _LiveGlpiConfig) -> AsyncGlpiClient: """Return one configured :class:`AsyncGlpiClient` for the live instance. Parameters @@ -59,9 +54,6 @@ def _build_async_client( config : _LiveGlpiConfig Live GLPI configuration loaded from the secrets directory or the ``GLPI_*`` environment variables. - executor : concurrent.futures.ThreadPoolExecutor | None - Optional pool routed through :class:`AsyncBridge`. When - ``None`` the bridge falls back to :func:`asyncio.to_thread`. """ return AsyncGlpiClient( @@ -77,7 +69,6 @@ def _build_async_client( v1_base_url=config.v1_base_url, v1_user_token=config.v1_user_token, v1_app_token=config.v1_app_token, - executor=executor, ) @@ -172,7 +163,7 @@ async def test_gather_fan_out_read_only( """Fan out independent read-only calls and confirm each returns a list. This stresses the bridge: many coroutines hit - :func:`asyncio.to_thread` simultaneously, contending for the OAuth + the event loop simultaneously, contending for the OAuth token lock on the first call and then for the requests pool. """ @@ -208,63 +199,55 @@ async def test_oauth_lock_contention_on_fresh_client( assert isinstance(value, list) -async def test_custom_executor_routing( - live_config: _LiveGlpiConfig, # noqa: F811 +async def test_calls_run_on_the_event_loop_not_a_worker_thread( + async_client: AsyncGlpiClient, ) -> None: - """A caller-supplied executor handles every wrapped call. + """Requests are issued from the calling thread, on the event loop. - The bridge takes the ``glpi-itest`` named pool when one is passed - in. We check that thread names observed during fan-out come from - that pool, proving the bridge respects the override. - """ + This replaces an earlier test that asserted a caller-supplied + executor received the work. There is no executor and no worker thread + any more: the client performs real non-blocking I/O, so the whole + call runs on the thread that owns the loop. - seen_thread_names: set[str] = set() - - pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="glpi-itest") - try: - async with _build_async_client(live_config, executor=pool) as client: - results = await asyncio.gather( - *(client.search_users(limit=1) for _ in range(6)) - ) - assert len(results) == 6 + A live fan-out is the honest way to check it -- a stubbed transport + would prove nothing about how real sockets are driven. + """ - # Quick worker-name probe: schedule a no-op through the same - # pool and capture the worker thread name. - loop = asyncio.get_running_loop() + import threading - def _name() -> str: - import threading + main_thread = threading.current_thread().name + observed: list[str] = [] - return threading.current_thread().name + async def _probe() -> None: + await async_client.search_users(limit=1) + observed.append(threading.current_thread().name) - for _ in range(4): - seen_thread_names.add(await loop.run_in_executor(pool, _name)) - finally: - pool.shutdown(wait=True) + await asyncio.gather(*(_probe() for _ in range(6))) - assert seen_thread_names, "expected at least one worker name to be captured" - assert all(name.startswith("glpi-itest") for name in seen_thread_names) + assert len(observed) == 6 + assert all(name == main_thread for name in observed), ( + f"calls ran off the loop thread: {set(observed)}" + ) async def test_cancellation_releases_awaiter( async_client: AsyncGlpiClient, ) -> None: - """Cancelling the awaiter releases the coroutine even if HTTP keeps running. + """Cancelling the task raises ``CancelledError`` promptly. - The bridge documents cancellation as best-effort: the in-flight - ``requests`` call still completes on its worker thread, but the - awaiting coroutine must raise :class:`asyncio.CancelledError` - promptly. + With real async I/O the cancellation reaches the in-flight request + itself rather than being best-effort against a detached worker + thread, so the awaiting coroutine must unwind immediately. """ task = asyncio.create_task(async_client.search_tickets("status==1", limit=50)) - await asyncio.sleep(0) # let the task enter to_thread + await asyncio.sleep(0) # let the task reach its first await task.cancel() with pytest.raises(asyncio.CancelledError): await task -async def test_exception_propagates_from_worker_thread( +async def test_exception_propagates_from_an_awaited_call( async_client: AsyncGlpiClient, ) -> None: """A missing ticket surfaces as a typed GLPI error. From 7e5d740edb17191922cd6ea8cef7a504681255c3 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 27 Jul 2026 23:48:09 +0200 Subject: [PATCH 6/9] docs: repair the references the codegen rewrite left behind The rewrite renamed one client module and deleted four others; the documentation did not follow. Nothing caught it. Sphinx runs with `nitpicky` off, so an unresolvable target renders as plain text rather than failing the build, and the private modules holding most of these references are never autodoc'd in the first place. The user-facing half was the worse half. The README, the user guide, the API reference, the package docstring and two skills all still described the deleted bridge: an async facade wrapping each synchronous method into a coroutine dispatched to a worker thread. The user guide's "Custom thread pools" section and step 8 of `glpi-client-setup` went further and documented an `executor=` constructor argument that no longer exists, so both examples raise `TypeError` as written. That section is now "Bounding concurrency" and shows an `asyncio.Semaphore`. Separately, the generated tree documented itself in terms of the hand-written one. unasync repoints imports, where `_async` is its own NAME token, but a dotted path inside a docstring is a single string token and survives untouched, so 47 cross-references in the shipped sync client pointed into `_async/`. The diff gate cannot see this for the same reason it cannot see a token collision: the omission is deterministic, so regeneration reproduces it and the diff stays clean. `unasync_build` now repoints the qualified prefix. Two guards keep it from recurring, both mutation-checked: * `test_docstring_references.py` resolves every qualified reference in the package against the live modules. Writing it turned up two more dead targets that reading had missed. * `test_the_generated_tree_never_names_the_async_one` asserts the sync tree never mentions `_async`. Disabling the repointing makes it report 28 offenders. Also corrects `TicketContextMixin`, which claimed its five calls ran sequentially and that an async override fanned them out, contradicting both the code and its own method docstring; and drops the `requests` intersphinx mapping, which survived the transport swap and made every docs build fetch an inventory nothing referenced. 527 unit tests pass, coverage 96.95%, mypy strict clean over 119 files, sphinx -W clean, `_sync/` verified identical to regeneration. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 37 ++++++ README.md | 32 ++--- docs/api_reference.rst | 4 +- docs/conf.py | 1 - docs/development.md | 83 +++++++------ docs/user_guide.rst | 46 ++++---- glpi_python_client/__init__.py | 11 +- .../_async/clients/_base_client.py | 4 +- .../clients/api/administration/__init__.py | 4 +- .../clients/api/management/_document.py | 7 +- .../_async/clients/api/plugins/_fields.py | 4 +- .../_async/clients/commons/_transport.py | 14 +-- .../_async/clients/custom/_ticket_context.py | 9 +- glpi_python_client/_sync/clients/__init__.py | 6 +- .../_sync/clients/_base_client.py | 4 +- .../_sync/clients/api/__init__.py | 2 +- .../clients/api/administration/__init__.py | 4 +- .../api/assistance/timeline/_followup.py | 2 +- .../api/assistance/timeline/_solution.py | 2 +- .../clients/api/assistance/timeline/_task.py | 2 +- .../_sync/clients/api/management/_document.py | 7 +- .../_sync/clients/api/plugins/__init__.py | 2 +- .../_sync/clients/api/plugins/_fields.py | 6 +- glpi_python_client/_sync/clients/client.py | 10 +- .../_sync/clients/commons/__init__.py | 4 +- .../_sync/clients/commons/_transport.py | 20 ++-- .../_sync/clients/custom/__init__.py | 4 +- .../_sync/clients/custom/_statistics.py | 2 +- .../_sync/clients/custom/_ticket_context.py | 11 +- glpi_python_client/testing/utils.py | 2 +- .../tests/test_docstring_references.py | 109 ++++++++++++++++++ .../tests/test_unasync_codegen.py | 37 ++++++ integration_tests/test_integration_async.py | 6 +- skills/glpi-client-setup/SKILL.md | 12 +- skills/glpi-document-workflow/SKILL.md | 2 +- unasync_build.py | 44 ++++++- 36 files changed, 399 insertions(+), 157 deletions(-) create mode 100644 glpi_python_client/tests/test_docstring_references.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a4e24..73d40be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,6 +241,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). module and its `remote_error_message` helper are removed. It had no library call sites, and `reraise=True` leaves it nothing to unwrap. +### Documentation + +- **The documentation still described the deleted bridge**, in the places + users actually read: the README, the user guide, the API reference, the + package docstring, and two skills all said `AsyncGlpiClient` wraps each + synchronous method into a coroutine dispatched to a worker thread via + `asyncio.to_thread`. None of that has been true since the codegen + rewrite. The worst of it was live API: the user guide's *Custom thread + pools* section and step 8 of `glpi-client-setup` documented an + `executor=` constructor argument that no longer exists, so anyone + copying either example got a `TypeError`. That section is now + *Bounding concurrency* and shows an `asyncio.Semaphore`, which is what + actually bounds a fan-out now. +- **Fourteen docstring cross-references pointed at modules the rewrite had + renamed or deleted** (`clients.async_client`, `clients.sync_client`, + `custom._ticket_context_async`). Nothing caught them: Sphinx runs with + `nitpicky` off, so an unresolvable target renders as plain text rather + than failing the build, and these private modules are not autodoc'd in + the first place. `tests/test_docstring_references.py` now resolves every + qualified reference in the package against the live modules. +- **The generated sync tree documented itself in terms of the async one.** + unasync repoints imports, where `_async` is its own NAME token, but a + dotted path inside a docstring is a single string token and passes + through untouched — so 47 cross-references in the shipped sync client + pointed into `_async/`. The diff gate is blind to this for the same + reason it is blind to a token collision: the omission is deterministic, + so regeneration reproduces it and the diff stays clean. `unasync_build` + now repoints the qualified prefix, and a test asserts the generated tree + never names `_async` at all. +- `TicketContextMixin` claimed its five calls ran sequentially and that an + async override fanned them out, contradicting both the code and its own + method docstring. The development guide still described the deleted + `_ticket_context_async.py` / `_statistics_async.py` and the retired + `test_parity.py` / `test_async_selfcall_guard.py` suites. +- The `requests` intersphinx mapping is removed; it survived the transport + swap and made every docs build fetch an inventory nothing referenced. + ### Unchanged (deliberately) - Retry semantics: 5xx retried 3 times with a 3-second fixed wait, 4xx never diff --git a/README.md b/README.md index a3cb685..261c068 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,12 @@ back to HTML for outgoing payloads. It currently focuses on ticket-centric workflows and exposes two high-level clients built on top of the GLPI v2 REST API: -- `GlpiClient` — synchronous, blocking client (single source of truth for - endpoint behaviour). -- `AsyncGlpiClient` — asynchronous facade that wraps every synchronous - method into a coroutine and dispatches it to a worker thread. +- `GlpiClient` — synchronous, blocking client. +- `AsyncGlpiClient` — asynchronous client doing real non-blocking I/O on the + event loop. + +Neither wraps the other. The async tree is hand-written and the sync one is +generated from it, so the two surfaces cannot drift apart. Note that all integration tests using this package are made on GLPI 11. I cannot make any guarantee of the behaviour on previous versions. @@ -106,15 +108,19 @@ variables. ### Sync or async? Both clients expose the exact same endpoint surface and accept the same -constructor arguments. The async client is a thin facade that wraps each -synchronous method into a coroutine dispatched to a worker thread via -`asyncio.to_thread` (or a caller-supplied `concurrent.futures.Executor`). -A shared `threading.Lock` serialises OAuth token acquisition so concurrent -`asyncio.gather(...)` fan-outs cannot race. Pick `GlpiClient` for plain -scripts, CLI tools, and synchronous services; pick `AsyncGlpiClient` when -your application already runs an event loop or when you need concurrent -fan-out (the aggregated `get_ticket_context` and per-ticket -`get_task_statistics` helpers use `asyncio.gather` on the async client). +constructor arguments, because both are generated from one source: the +async tree is hand-written and the sync one is produced from it by a build +step that strips `async`/`await`. `AsyncGlpiClient` performs real +non-blocking I/O on the event loop — there is no worker thread and no +executor — and `GlpiClient` performs real blocking I/O with no coroutine +scheduling. An auth lock serialises OAuth token acquisition on both +surfaces, so concurrent fan-outs cannot race. + +Pick `GlpiClient` for plain scripts, CLI tools, and synchronous services; +pick `AsyncGlpiClient` when your application already runs an event loop or +when you need concurrent fan-out (the aggregated `get_ticket_context` and +per-ticket `get_task_statistics` helpers overlap their calls there, and run +them one after another on the sync client). ## Documentation diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 11b58c6..61ab4af 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -11,8 +11,8 @@ Clients ------- The package exposes two clients with identical endpoint surfaces. The -synchronous one is the single source of truth for endpoint behaviour; -the asynchronous one wraps each synchronous method into a coroutine. +asynchronous one is hand-written and the synchronous one is generated +from it, so neither wraps the other and the two cannot drift apart. .. autoclass:: GlpiClient :members: diff --git a/docs/conf.py b/docs/conf.py index f231e23..9fbb13c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,5 +62,4 @@ def _read_project_version() -> str: intersphinx_mapping = { "python": ("https://docs.python.org/3", None), - "requests": ("https://requests.readthedocs.io/en/latest/", None), } diff --git a/docs/development.md b/docs/development.md index e65baa8..dd14b68 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,43 +42,43 @@ python -m pytest - `glpi_python_client.__init__` exposes the public import surface, including both client classes and the Pydantic models. -- `glpi_python_client._sync.clients.client.GlpiClient` is the - synchronous, blocking client. It is the single source of truth for - endpoint behaviour: each public method lives on one of the sync - endpoint mixins under `glpi_python_client._async.clients.api.*` and - `glpi_python_client._async.clients.custom.*`. -- `glpi_python_client._async.clients.client.AsyncGlpiClient` is the - asynchronous facade. It inherits the same endpoint mixins and uses - `glpi_python_client._async._concurrency` to wrap - every inherited public sync method into a coroutine dispatched on a - worker thread (`asyncio.to_thread` by default, or a caller-supplied - `concurrent.futures.Executor`). +- `glpi_python_client/_async/` is the **only hand-written client tree**, + and `glpi_python_client/_sync/` is generated from it by + `unasync_build.py` and committed. Every endpoint method is therefore + written exactly once, as an `async def`, and the sync client is a + mechanical token transform of it. CI regenerates the tree and fails on + any difference. +- `glpi_python_client._async.clients.client.AsyncGlpiClient` and its + generated twin `glpi_python_client._sync.clients.client.GlpiClient` are + composed from the same endpoint mixins. Neither wraps the other: + the async client performs real non-blocking I/O and the sync one real + blocking I/O. +- `glpi_python_client._async._concurrency` is the one module hand-written + on *both* sides, because the two surfaces need primitives that differ + in kind rather than in spelling: the auth lock is an `asyncio.Lock` + against a `threading.Lock`, and `gather` is a real fan-out against + plain sequential evaluation. Neither substitutes for the other — see + the module for what breaks in each direction. Call `gather` from there + for concurrent fan-out, never `asyncio.gather` directly, which unasync + would leave intact and emit as broken sync code. - `glpi_python_client._async.clients.commons` holds the reusable building blocks shared by every endpoint mixin: configuration helpers - (`_config`), constants (`_constants`), errors (`_errors`), filters - (`_filters`), HTTP helpers (`_http`), payload builders (`_payloads`), - and the `TransportMixin` (`_transport`). The transport serialises OAuth - token acquisition with the lock from `_async/_concurrency.py`, so - concurrent callers cannot race the token manager. + (`_config`), constants (`_constants`), filters (`_filters`), HTTP + helpers (`_http`), payload builders (`_payloads`), and the + `TransportMixin` (`_transport`). The transport serialises OAuth token + acquisition with the lock from `_concurrency`, so concurrent callers + cannot race the token manager. +- `glpi_python_client._errors` defines the public exception hierarchy + (`GlpiError` and its subclasses), re-exported from the package root. - `glpi_python_client._async.clients.api.*` contains the contract-aligned endpoint mixins, grouped by GLPI subtree (administration, assistance, assistance/timeline, dropdowns, management, knowledgebase, plugins). - Each mixin is written once, in the async tree. (Historically some had - hand-written async overrides needed - because their synchronous bodies call a sibling public method through - `self` (see the `clients.custom` entry below for the other reason a - method needs one). -- `glpi_python_client._async.clients.custom` contains custom helpers built on - top of the API mixins. Each helper has a synchronous implementation - (`_ticket_context.py`, `_statistics.py`) plus an async override - (`_ticket_context_async.py`, `_statistics_async.py`) that fans the - underlying calls out concurrently with `asyncio.gather`. That is one - of two reasons a method needs a hand-written async override; the - other — a synchronous body calling a sibling public method through - `self` — is why `clients.api.knowledgebase` and `clients.api.plugins` - also ship one (see above). +- `glpi_python_client._async.clients.custom` contains helpers built on top + of the API mixins (`_ticket_context.py`, `_statistics.py`). Their + independent calls go through `gather`, so one implementation fans out + on the async surface and runs sequentially on the generated one. - `glpi_python_client._async.auth._v1_session` contains the legacy v1 - session used for binary document uploads. + session used for binary document uploads and the `Fields` plugin. - `glpi_python_client.models` contains typed request and response models. - `glpi_python_client.content` handles HTML/Markdown conversion for @@ -111,13 +111,20 @@ python -m pytest pagination logic in the focused `glpi_python_client._async.clients.commons` helper module named for that responsibility. -4. Add unit tests for payload serialization, response parsing, and - client behavior. The parity test in - `glpi_python_client/clients/tests/test_parity.py` will fail if the - sync and async surfaces diverge, and - `glpi_python_client/clients/tests/test_async_selfcall_guard.py` will - fail if a public method reaches another public method through `self` - without a hand-written async override. +4. Add unit tests under `glpi_python_client/tests/**` for payload + serialization, response parsing, and client behaviour. They exercise + the generated sync client, which shares its statements with the async + one. + + Two suites cover what that cannot reach. + `glpi_python_client/tests/test_unasync_codegen.py` holds the + invariants the CI diff gate is blind to — a token collision is + deterministic, so regeneration reproduces it and the diff stays clean. + `glpi_python_client/tests/test_async_surface.py` runs the async client + for real: that a method actually awaits, that contending tasks do not + deadlock on the auth lock, and that `gather` genuinely overlaps its + arguments. Add to it whenever a change is only observable once + `async`/`await` are real. 5. Document the new workflow in `docs/user_guide.rst` or the README. Keep organization-specific defaults outside the package core. diff --git a/docs/user_guide.rst b/docs/user_guide.rst index b064c5c..90a4d94 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -5,10 +5,12 @@ The ``glpi_python_client`` package exposes two high-level clients whose surface is built from contract-aligned per-endpoint mixins: * :class:`glpi_python_client.GlpiClient` — synchronous, blocking - client. The single source of truth for endpoint behaviour. -* :class:`glpi_python_client.AsyncGlpiClient` — asynchronous facade - that wraps every synchronous method into a coroutine and dispatches - it to a worker thread via :func:`asyncio.to_thread`. + client. +* :class:`glpi_python_client.AsyncGlpiClient` — asynchronous client + doing real non-blocking I/O on the event loop. + +Neither is a wrapper around the other; both are the same code, as +*Sync vs async surface* below explains. Both clients speak the GLPI **v2** high-level API and fall back to the legacy v1 API for features that are not exposed by v2, currently @@ -29,8 +31,8 @@ The guide is split into the following sections: 1. **Creating a client** — how to instantiate either client from explicit parameters or from environment variables. -2. **Sync vs async surface** — when to pick which client and how the - async facade is implemented. +2. **Sync vs async surface** — when to pick which client and how both + are produced from a single source. 3. **Seed data for the examples** — a self-contained snippet that creates the records reused by every later example. Run it once on a throwaway GLPI instance to follow along. @@ -119,10 +121,6 @@ Optional constructor arguments fallback used by :meth:`GlpiClient.upload_document` and the ``Fields`` plugin helpers such as :meth:`GlpiClient.get_ticket_custom_fields`. -* ``executor`` (:class:`AsyncGlpiClient` only) — an explicit - :class:`concurrent.futures.Executor` used to dispatch the wrapped - synchronous calls. Defaults to the standard library thread pool - through :func:`asyncio.to_thread`. ``from_env`` ~~~~~~~~~~~~ @@ -214,26 +212,34 @@ without blocking the event loop: The synchronous versions of the same helpers issue the calls sequentially. -Custom thread pools -~~~~~~~~~~~~~~~~~~~ +Bounding concurrency +~~~~~~~~~~~~~~~~~~~~ + +There is no thread pool to size and no ``executor`` argument: the async +client issues real non-blocking requests, so concurrency is bounded by +the underlying HTTP connection pool rather than by worker threads. -Applications that want to bound the worker pool size, name the worker -threads, or share a pool with other components can pass an explicit -executor: +To keep a large fan-out from overwhelming the GLPI server, bound it on +your side with an :class:`asyncio.Semaphore`: .. code-block:: python import asyncio - from concurrent.futures import ThreadPoolExecutor from glpi_python_client import AsyncGlpiClient async def main() -> None: - with ThreadPoolExecutor(max_workers=8, thread_name_prefix="glpi") as pool: - async with AsyncGlpiClient.from_env(executor=pool) as client: - tickets = await client.search_tickets("status==1", limit=200) - print(len(tickets)) + limit = asyncio.Semaphore(8) + + async with AsyncGlpiClient.from_env() as client: + + async def fetch(ticket_id: int): + async with limit: + return await client.get_ticket(ticket_id) + + tickets = await asyncio.gather(*(fetch(i) for i in range(1, 101))) + print(len(tickets)) asyncio.run(main()) diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index eecafaa..fd49b9c 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -2,10 +2,13 @@ The package re-exports two client classes: -* :class:`GlpiClient` — synchronous, blocking client (single source of - truth for endpoint behaviour). -* :class:`AsyncGlpiClient` — asynchronous facade that wraps each - synchronous method into a coroutine. +* :class:`GlpiClient` — synchronous, blocking client. +* :class:`AsyncGlpiClient` — asynchronous client doing real + non-blocking I/O on the event loop. + +They expose identical endpoint surfaces because they are the same +code: the async tree is hand-written and the sync one is generated +from it. Neither is a wrapper around the other. The ``api_schema`` and ``custom_schema`` Pydantic models are also re-exported so downstream users can import them from a single stable diff --git a/glpi_python_client/_async/clients/_base_client.py b/glpi_python_client/_async/clients/_base_client.py index 785e3b8..90c9c40 100644 --- a/glpi_python_client/_async/clients/_base_client.py +++ b/glpi_python_client/_async/clients/_base_client.py @@ -2,8 +2,8 @@ The :class:`_BaseGlpiClient` mixin holds the constructor signature, the resource-bundle assignment, and the :meth:`from_env` classmethod that -both :class:`~glpi_python_client._async.clients.sync_client.GlpiClient` and -:class:`~glpi_python_client._async.clients.async_client.AsyncGlpiClient` use. +both :class:`~glpi_python_client.GlpiClient` and +:class:`~glpi_python_client.AsyncGlpiClient` use. Lifecycle helpers (``close``, ``__enter__``/``__exit__`` versus ``__aenter__``/``__aexit__``) stay on the concrete subclasses because they differ between the sync and async surfaces. diff --git a/glpi_python_client/_async/clients/api/administration/__init__.py b/glpi_python_client/_async/clients/api/administration/__init__.py index 228b403..c05991d 100644 --- a/glpi_python_client/_async/clients/api/administration/__init__.py +++ b/glpi_python_client/_async/clients/api/administration/__init__.py @@ -1,8 +1,8 @@ """GLPI ``/Administration`` mixins for the GLPI client. The submodules expose the user and entity mixins used by -:class:`glpi_python_client._async.clients.sync_client.GlpiClient` and -:class:`glpi_python_client._async.clients.async_client.AsyncGlpiClient`. +:class:`glpi_python_client.GlpiClient` and +:class:`glpi_python_client.AsyncGlpiClient`. """ from __future__ import annotations diff --git a/glpi_python_client/_async/clients/api/management/_document.py b/glpi_python_client/_async/clients/api/management/_document.py index 3f57e3e..11a32bb 100644 --- a/glpi_python_client/_async/clients/api/management/_document.py +++ b/glpi_python_client/_async/clients/api/management/_document.py @@ -228,10 +228,9 @@ async def upload_document( Document uploads use the legacy v1 multipart endpoint because the GLPI v2 API does not advertise a binary upload route. The - async :class:`~glpi_python_client._async.clients.AsyncGlpiClient` - offloads this blocking call to a worker thread automatically; - callers using the sync :class:`~glpi_python_client._async.clients.GlpiClient` - invoke it directly. + upload is dispatched through the same v1 session as every other + v1 call, so it needs ``v1_base_url`` and ``v1_user_token`` to be + configured on the client. Parameters ---------- diff --git a/glpi_python_client/_async/clients/api/plugins/_fields.py b/glpi_python_client/_async/clients/api/plugins/_fields.py index a7ba61b..82f9aaa 100644 --- a/glpi_python_client/_async/clients/api/plugins/_fields.py +++ b/glpi_python_client/_async/clients/api/plugins/_fields.py @@ -117,8 +117,8 @@ class PluginFieldsMixin(TransportMixin): """Helpers for the GLPI ``Fields`` plugin v1 endpoints. Every method requires the v1 session to be configured on the client - (see :class:`~glpi_python_client._async.clients.sync_client.GlpiClient`'s - ``v1_base_url`` and ``v1_user_token`` constructor arguments). + (see the ``v1_base_url`` and ``v1_user_token`` constructor + arguments). """ async def list_plugin_fields_containers( diff --git a/glpi_python_client/_async/clients/commons/_transport.py b/glpi_python_client/_async/clients/commons/_transport.py index 7ec4341..4303eea 100644 --- a/glpi_python_client/_async/clients/commons/_transport.py +++ b/glpi_python_client/_async/clients/commons/_transport.py @@ -89,13 +89,13 @@ class TransportMixin: Thread safety ------------- - Token acquisition and refresh are serialised by ``_auth_lock`` - (:class:`threading.Lock`) so concurrent threads — whether spawned by - the sync client directly or by the async client through - :func:`asyncio.to_thread` — never race while updating shared - authentication state. HTTP dispatch runs outside the lock and relies - on the thread-safety of :class:`httpx.AsyncClient` for concurrent - calls. + Token acquisition and refresh are serialised by ``_auth_lock``, the + ``Lock`` from :mod:`glpi_python_client._async._concurrency`, so + concurrent callers never race while updating shared authentication + state. That module is hand-written on both surfaces because the + right primitive differs in kind between them. HTTP dispatch runs + outside the lock and relies on the underlying httpx client being + safe to use from concurrent callers. """ _auth: GLPITokenManager diff --git a/glpi_python_client/_async/clients/custom/_ticket_context.py b/glpi_python_client/_async/clients/custom/_ticket_context.py index 88d1152..d32788e 100644 --- a/glpi_python_client/_async/clients/custom/_ticket_context.py +++ b/glpi_python_client/_async/clients/custom/_ticket_context.py @@ -18,10 +18,11 @@ class TicketContextMixin(TransportMixin): The mixin assumes the consuming client also exposes the ticket and ticket-timeline helpers from :mod:`glpi_python_client._async.clients.api`. - The five underlying calls are executed sequentially; the async - variant under - :mod:`glpi_python_client._async.clients.custom._ticket_context_async` - overrides :meth:`get_ticket_context` to fan them out concurrently. + The five underlying calls are independent and are issued through the + ``gather`` helper from + :mod:`glpi_python_client._async._concurrency`, so this one + implementation fans them out on the async surface and runs them one + after another on the generated sync one. """ async def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext: diff --git a/glpi_python_client/_sync/clients/__init__.py b/glpi_python_client/_sync/clients/__init__.py index 5a4c21a..2933dc5 100644 --- a/glpi_python_client/_sync/clients/__init__.py +++ b/glpi_python_client/_sync/clients/__init__.py @@ -1,9 +1,9 @@ """Client class for one GLPI surface. The concrete client composes every per-endpoint mixin from -:mod:`glpi_python_client._async.clients.api`, the aggregated helpers from -:mod:`glpi_python_client._async.clients.custom`, and the transport mixin -from :mod:`glpi_python_client._async.clients.commons`. +:mod:`glpi_python_client._sync.clients.api`, the aggregated helpers from +:mod:`glpi_python_client._sync.clients.custom`, and the transport mixin +from :mod:`glpi_python_client._sync.clients.commons`. Only one of the two client trees is written by hand; the other is generated from it. Both expose the same endpoint surface, so the choice diff --git a/glpi_python_client/_sync/clients/_base_client.py b/glpi_python_client/_sync/clients/_base_client.py index cc140a7..d5ee4c3 100644 --- a/glpi_python_client/_sync/clients/_base_client.py +++ b/glpi_python_client/_sync/clients/_base_client.py @@ -2,8 +2,8 @@ The :class:`_BaseGlpiClient` mixin holds the constructor signature, the resource-bundle assignment, and the :meth:`from_env` classmethod that -both :class:`~glpi_python_client._async.clients.sync_client.GlpiClient` and -:class:`~glpi_python_client._async.clients.async_client.AsyncGlpiClient` use. +both :class:`~glpi_python_client.GlpiClient` and +:class:`~glpi_python_client.AsyncGlpiClient` use. Lifecycle helpers (``close``, ``__enter__``/``__exit__`` versus ``__aenter__``/``__aexit__``) stay on the concrete subclasses because they differ between the sync and async surfaces. diff --git a/glpi_python_client/_sync/clients/api/__init__.py b/glpi_python_client/_sync/clients/api/__init__.py index 91e9107..577f705 100644 --- a/glpi_python_client/_sync/clients/api/__init__.py +++ b/glpi_python_client/_sync/clients/api/__init__.py @@ -2,7 +2,7 @@ The mixins under this package mirror the endpoints documented in ``docs/glpi_api_contract.json`` one for one. They wrap the -transport helpers from :mod:`glpi_python_client._async.clients.commons` and exchange +transport helpers from :mod:`glpi_python_client._sync.clients.commons` and exchange typed ``Get``, ``Post``, ``Patch``, and ``Delete`` models with the GLPI API. """ diff --git a/glpi_python_client/_sync/clients/api/administration/__init__.py b/glpi_python_client/_sync/clients/api/administration/__init__.py index 2194c3e..497758c 100644 --- a/glpi_python_client/_sync/clients/api/administration/__init__.py +++ b/glpi_python_client/_sync/clients/api/administration/__init__.py @@ -1,8 +1,8 @@ """GLPI ``/Administration`` mixins for the GLPI client. The submodules expose the user and entity mixins used by -:class:`glpi_python_client._async.clients.sync_client.GlpiClient` and -:class:`glpi_python_client._async.clients.async_client.AsyncGlpiClient`. +:class:`glpi_python_client.GlpiClient` and +:class:`glpi_python_client.AsyncGlpiClient`. """ from __future__ import annotations diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py index 209e7b2..b9e6f8e 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py @@ -11,7 +11,7 @@ the OpenAPI contract documents a flat array of ``ITILFollowup``. Real behaviour wins over the contract, so :func:`list_ticket_followups` unwraps the envelope via the shared -:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` +:meth:`~glpi_python_client._sync.clients.commons._transport.TransportMixin._resource_list` helper and tolerates both shapes. """ diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py index 0308347..e95a407 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py @@ -10,7 +10,7 @@ the OpenAPI contract documents a flat array of ``ITILSolution``. Real behaviour wins over the contract, so :func:`list_ticket_solutions` unwraps the envelope through the shared -:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` +:meth:`~glpi_python_client._sync.clients.commons._transport.TransportMixin._resource_list` helper and tolerates both shapes. """ diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py index efcb571..210397a 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py @@ -11,7 +11,7 @@ OpenAPI contract documents a flat array of ``TicketTask``. Real behaviour wins over the contract, so :func:`list_ticket_tasks` unwraps the envelope through the shared -:meth:`~glpi_python_client._async.clients.commons._transport.TransportMixin._resource_list` +:meth:`~glpi_python_client._sync.clients.commons._transport.TransportMixin._resource_list` helper and tolerates both shapes. """ diff --git a/glpi_python_client/_sync/clients/api/management/_document.py b/glpi_python_client/_sync/clients/api/management/_document.py index 2dfe73e..71cda34 100644 --- a/glpi_python_client/_sync/clients/api/management/_document.py +++ b/glpi_python_client/_sync/clients/api/management/_document.py @@ -228,10 +228,9 @@ def upload_document( Document uploads use the legacy v1 multipart endpoint because the GLPI v2 API does not advertise a binary upload route. The - async :class:`~glpi_python_client._async.clients.AsyncGlpiClient` - offloads this blocking call to a worker thread automatically; - callers using the sync :class:`~glpi_python_client._async.clients.GlpiClient` - invoke it directly. + upload is dispatched through the same v1 session as every other + v1 call, so it needs ``v1_base_url`` and ``v1_user_token`` to be + configured on the client. Parameters ---------- diff --git a/glpi_python_client/_sync/clients/api/plugins/__init__.py b/glpi_python_client/_sync/clients/api/plugins/__init__.py index dfbd0bb..0c6f1b9 100644 --- a/glpi_python_client/_sync/clients/api/plugins/__init__.py +++ b/glpi_python_client/_sync/clients/api/plugins/__init__.py @@ -2,7 +2,7 @@ Plugins are not advertised in the v2 OpenAPI contract so the mixins under this package go through the v1 session helper exposed by -:class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`. +:class:`~glpi_python_client._sync.auth._v1_session.GLPIV1Session`. """ from glpi_python_client._sync.clients.api.plugins._fields import PluginFieldsMixin diff --git a/glpi_python_client/_sync/clients/api/plugins/_fields.py b/glpi_python_client/_sync/clients/api/plugins/_fields.py index 326514d..b4970d0 100644 --- a/glpi_python_client/_sync/clients/api/plugins/_fields.py +++ b/glpi_python_client/_sync/clients/api/plugins/_fields.py @@ -3,7 +3,7 @@ The `Fields plugin `_ adds user-defined custom fields to any GLPI itemtype. It is not exposed through the GLPI v2 REST contract so this mixin talks to the legacy v1 -REST API through :class:`~glpi_python_client._async.auth._v1_session.GLPIV1Session`. +REST API through :class:`~glpi_python_client._sync.auth._v1_session.GLPIV1Session`. Two abstraction layers are provided: @@ -117,8 +117,8 @@ class PluginFieldsMixin(TransportMixin): """Helpers for the GLPI ``Fields`` plugin v1 endpoints. Every method requires the v1 session to be configured on the client - (see :class:`~glpi_python_client._async.clients.sync_client.GlpiClient`'s - ``v1_base_url`` and ``v1_user_token`` constructor arguments). + (see the ``v1_base_url`` and ``v1_user_token`` constructor + arguments). """ def list_plugin_fields_containers( diff --git a/glpi_python_client/_sync/clients/client.py b/glpi_python_client/_sync/clients/client.py index 8260ea1..2164daa 100644 --- a/glpi_python_client/_sync/clients/client.py +++ b/glpi_python_client/_sync/clients/client.py @@ -1,9 +1,9 @@ """Public GLPI client class. Composes the per-endpoint mixins from -:mod:`glpi_python_client._async.clients.api` with the aggregated helpers -from :mod:`glpi_python_client._async.clients.custom` and the transport -mixin from :mod:`glpi_python_client._async.clients.commons` to expose the +:mod:`glpi_python_client._sync.clients.api` with the aggregated helpers +from :mod:`glpi_python_client._sync.clients.custom` and the transport +mixin from :mod:`glpi_python_client._sync.clients.commons` to expose the full public client surface. This module is written once. Its counterpart on the other surface is @@ -75,12 +75,12 @@ class GlpiClient( The client owns the shared HTTP session, the OAuth token manager, and the optional legacy v1 session used for binary document uploads and the Fields plugin endpoints. Token acquisition is serialised by the - lock from :mod:`glpi_python_client._async._concurrency`, which is the + lock from :mod:`glpi_python_client._sync._concurrency`, which is the right primitive for this surface -- see that module for why the two surfaces cannot share one. Construction parameters and :meth:`from_env` are documented on - :class:`~glpi_python_client._async.clients._base_client._BaseGlpiClient`. + :class:`~glpi_python_client._sync.clients._base_client._BaseGlpiClient`. """ def close(self) -> None: diff --git a/glpi_python_client/_sync/clients/commons/__init__.py b/glpi_python_client/_sync/clients/commons/__init__.py index 149375a..ce78073 100644 --- a/glpi_python_client/_sync/clients/commons/__init__.py +++ b/glpi_python_client/_sync/clients/commons/__init__.py @@ -2,8 +2,8 @@ The commons package centralises constants, HTTP helpers, RSQL filter builders, transport, and the client configuration helpers -used by the per-endpoint mixins under :mod:`glpi_python_client._async.clients.api` -and the higher-level helpers under :mod:`glpi_python_client._async.clients.custom`. +used by the per-endpoint mixins under :mod:`glpi_python_client._sync.clients.api` +and the higher-level helpers under :mod:`glpi_python_client._sync.clients.custom`. """ from __future__ import annotations diff --git a/glpi_python_client/_sync/clients/commons/_transport.py b/glpi_python_client/_sync/clients/commons/_transport.py index d9d1866..ec24d51 100644 --- a/glpi_python_client/_sync/clients/commons/_transport.py +++ b/glpi_python_client/_sync/clients/commons/_transport.py @@ -2,13 +2,13 @@ The transport mixin owns token handling, header construction, retries, and HTTP request dispatch so the per-endpoint mixins under -:mod:`glpi_python_client._async.clients.api` can stay focused on resource-specific +:mod:`glpi_python_client._sync.clients.api` can stay focused on resource-specific behaviour. Concurrency model ----------------- Access to the auth token manager is serialised with the lock from -:mod:`glpi_python_client._async._concurrency`. That module is one of only +:mod:`glpi_python_client._sync._concurrency`. That module is one of only two maintained separately for each surface, because the correct primitive genuinely differs: an :class:`asyncio.Lock` for concurrent tasks on one event loop, a :class:`threading.Lock` for a client shared across threads. @@ -68,7 +68,7 @@ #: the different libraries are completely disjoint — and retries then vanish #: with no error, no warning and a green test suite. Naming #: :class:`~glpi_python_client.GlpiTransportError`, which -#: :func:`~glpi_python_client._async.clients.commons._http.transport_error_from` +#: :func:`~glpi_python_client._sync.clients.commons._http.transport_error_from` #: guarantees every network fault is translated into, makes that failure #: impossible to reintroduce. _RETRY_ON_NETWORK_ERRORS = retry( @@ -89,13 +89,13 @@ class TransportMixin: Thread safety ------------- - Token acquisition and refresh are serialised by ``_auth_lock`` - (:class:`threading.Lock`) so concurrent threads — whether spawned by - the sync client directly or by the async client through - :func:`asyncio.to_thread` — never race while updating shared - authentication state. HTTP dispatch runs outside the lock and relies - on the thread-safety of :class:`httpx.AsyncClient` for concurrent - calls. + Token acquisition and refresh are serialised by ``_auth_lock``, the + ``Lock`` from :mod:`glpi_python_client._sync._concurrency`, so + concurrent callers never race while updating shared authentication + state. That module is hand-written on both surfaces because the + right primitive differs in kind between them. HTTP dispatch runs + outside the lock and relies on the underlying httpx client being + safe to use from concurrent callers. """ _auth: GLPITokenManager diff --git a/glpi_python_client/_sync/clients/custom/__init__.py b/glpi_python_client/_sync/clients/custom/__init__.py index 3b486eb..b590325 100644 --- a/glpi_python_client/_sync/clients/custom/__init__.py +++ b/glpi_python_client/_sync/clients/custom/__init__.py @@ -4,10 +4,10 @@ advertise directly but which client applications need: the aggregated ticket-context view and the reporting helpers, both assembled from the contract-aligned CRUD helpers in -:mod:`glpi_python_client._async.clients.api`. +:mod:`glpi_python_client._sync.clients.api`. Each helper is written once. The fan-out points call ``gather`` from -:mod:`glpi_python_client._async._concurrency`, which runs them +:mod:`glpi_python_client._sync._concurrency`, which runs them concurrently here and sequentially in the generated tree -- so there is no second copy of this logic to keep in step. """ diff --git a/glpi_python_client/_sync/clients/custom/_statistics.py b/glpi_python_client/_sync/clients/custom/_statistics.py index 5e1a4b1..0e2a25b 100644 --- a/glpi_python_client/_sync/clients/custom/_statistics.py +++ b/glpi_python_client/_sync/clients/custom/_statistics.py @@ -2,7 +2,7 @@ The mixin exposes simple aggregations over ticket and ticket-task results returned by the contract-aligned helpers in -:mod:`glpi_python_client._async.clients.api`. These operations are intentionally +:mod:`glpi_python_client._sync.clients.api`. These operations are intentionally kept small and do not perform name resolution or rich label formatting; the caller can correlate the returned numeric identifiers with the dedicated ``search_*`` helpers when required. diff --git a/glpi_python_client/_sync/clients/custom/_ticket_context.py b/glpi_python_client/_sync/clients/custom/_ticket_context.py index 8e68bd7..a7c64c4 100644 --- a/glpi_python_client/_sync/clients/custom/_ticket_context.py +++ b/glpi_python_client/_sync/clients/custom/_ticket_context.py @@ -17,11 +17,12 @@ class TicketContextMixin(TransportMixin): """Ticket-context aggregation helper. The mixin assumes the consuming client also exposes the ticket and - ticket-timeline helpers from :mod:`glpi_python_client._async.clients.api`. - The five underlying calls are executed sequentially; the async - variant under - :mod:`glpi_python_client._async.clients.custom._ticket_context_async` - overrides :meth:`get_ticket_context` to fan them out concurrently. + ticket-timeline helpers from :mod:`glpi_python_client._sync.clients.api`. + The five underlying calls are independent and are issued through the + ``gather`` helper from + :mod:`glpi_python_client._sync._concurrency`, so this one + implementation fans them out on the async surface and runs them one + after another on the generated sync one. """ def get_ticket_context(self, ticket_id: GlpiId) -> GlpiTicketContext: diff --git a/glpi_python_client/testing/utils.py b/glpi_python_client/testing/utils.py index 6058cc6..05e9786 100644 --- a/glpi_python_client/testing/utils.py +++ b/glpi_python_client/testing/utils.py @@ -26,7 +26,7 @@ class FakeResponse: by the package's tests. It is duck-typed rather than a subclass of the transport's response class, so it survived the move from ``requests`` to ``httpx`` unchanged: the library reads the reason phrase through - :func:`~glpi_python_client.clients.commons._http.response_reason`, which + :func:`~glpi_python_client._async.clients.commons._http.response_reason`, which accepts either the ``reason`` spelling used here or the ``reason_phrase`` spelling ``httpx`` uses. """ diff --git a/glpi_python_client/tests/test_docstring_references.py b/glpi_python_client/tests/test_docstring_references.py new file mode 100644 index 0000000..aa059b7 --- /dev/null +++ b/glpi_python_client/tests/test_docstring_references.py @@ -0,0 +1,109 @@ +"""Every qualified cross-reference in the package points at something real. + +Sphinx cannot provide this guarantee. ``nitpicky`` is off, so an +unresolvable ``:class:`` or ``:mod:`` target renders as plain text +instead of failing the build -- and most of these modules are private, +so autodoc never visits them in the first place. A reference to a module +that was since renamed or deleted therefore rots in complete silence. + +That is not hypothetical. The move to a generated sync tree renamed one +client module and deleted four others, and seven docstring references to +them survived the rewrite with a green suite, a green ``-W`` docs build, +and a clean codegen diff. This test is the check that would have caught +them, so the next rename cannot repeat it. +""" + +from __future__ import annotations + +import importlib +import pathlib +import re + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_PACKAGE = _REPO_ROOT / "glpi_python_client" + +#: A qualified reference to this package: the root name plus at least one +#: dotted segment. Bare mentions are excluded on purpose -- ``GlpiClient`` +#: on its own is prose, not a reference that can rot. +_REFERENCE = re.compile(r"\bglpi_python_client(?:\.[A-Za-z_]\w*)+") + + +def _resolves(dotted: str) -> bool: + """Return whether ``dotted`` names a real module or attribute chain. + + Tries the longest importable module prefix first, then walks the + remainder with :func:`getattr`. A reference may end in a module, a + class, or a method, and all three have to be accepted. + """ + + parts = dotted.split(".") + for stop in range(len(parts), 0, -1): + try: + target: object = importlib.import_module(".".join(parts[:stop])) + except ImportError: + continue + for attribute in parts[stop:]: + if hasattr(target, attribute): + target = getattr(target, attribute) + continue + # Pydantic v2 moves declared fields into ``model_fields`` and + # takes them out of the class namespace, so ``hasattr`` says no + # to a field that autodoc documents perfectly well. Accepting + # them matters: without this the check reports noise, and a + # check that cries wolf gets deleted. + fields = getattr(target, "model_fields", None) + if isinstance(fields, dict) and attribute in fields: + return True + return False + return True + return False + + +def _reference_sites() -> list[tuple[str, int, str]]: + """Return ``(module, lineno, reference)`` for every qualified mention.""" + + sites: list[tuple[str, int, str]] = [] + this_module = pathlib.Path(__file__).resolve() + for path in sorted(_PACKAGE.rglob("*.py")): + # This module names dead targets on purpose, as controls. + if "__pycache__" in path.parts or path.resolve() == this_module: + continue + rel = path.relative_to(_REPO_ROOT).as_posix() + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for match in _REFERENCE.findall(line): + sites.append((rel, lineno, match.rstrip("."))) + return sites + + +def test_the_scan_finds_references_and_can_tell_them_apart() -> None: + """Positive control: the scan is not vacuous and ``_resolves`` discriminates. + + Without this, a regex that stopped matching would turn the check + below into a test that passes forever without looking at anything. + """ + + sites = _reference_sites() + assert len(sites) > 50, f"only {len(sites)} references found -- regex broken?" + + assert _resolves("glpi_python_client.GlpiClient") + assert _resolves("glpi_python_client.AsyncGlpiClient.get_ticket_context") + assert _resolves("glpi_python_client._async.clients.commons._transport") + # One of the targets the codegen rewrite actually left behind. + assert not _resolves( + "glpi_python_client._async.clients.async_client.AsyncGlpiClient" + ) + assert not _resolves("glpi_python_client.nope") + + +def test_every_qualified_reference_resolves() -> None: + """No docstring names a module, class, or method that does not exist.""" + + offenders = [ + f"{module}:{lineno}: {reference}" + for module, lineno, reference in _reference_sites() + if not _resolves(reference) + ] + assert offenders == [], ( + "these references point at nothing -- the target was renamed or " + "deleted and the docstring was not updated:\n" + "\n".join(offenders) + ) diff --git a/glpi_python_client/tests/test_unasync_codegen.py b/glpi_python_client/tests/test_unasync_codegen.py index 11279ff..ae04e05 100644 --- a/glpi_python_client/tests/test_unasync_codegen.py +++ b/glpi_python_client/tests/test_unasync_codegen.py @@ -129,6 +129,43 @@ def test_no_identifier_collides_with_a_substitution_key() -> None: ) +def test_the_generated_tree_never_names_the_async_one() -> None: + """No module under ``_sync/`` mentions ``_async`` anywhere. + + unasync repoints the imports for free, because there ``_async`` is + its own NAME token. Inside a docstring the whole thing is a *single* + string token, and substitution only fires when a literal's entire + content is a key -- so a cross-reference such as + ``:mod:`glpi_python_client._async.clients.api``` passes straight + through, and the shipped sync client ends up documenting itself in + terms of a tree its users never import. + + The diff gate cannot see this either: the omission is deterministic, + so regeneration reproduces it and the diff stays clean. + ``unasync_build`` rewrites the qualified prefix; this asserts the + result from the other end, and additionally catches a *bare* mention + that the prefix rewrite is deliberately too narrow to touch. + + The hand-written twins are exempt. They are not generated, and each + one names its counterpart on purpose: pointing at the other file is + the only way to explain why the pair exists. + """ + + build = _build_module() + hand_written: set[str] = build.HAND_WRITTEN # type: ignore[attr-defined] + offenders = [ + f"{path.relative_to(_REPO_ROOT).as_posix()}:{lineno}: {line.strip()}" + for path in sorted(_SYNC_DIR.rglob("*.py")) + if "__pycache__" not in path.parts and path.name not in hand_written + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if "_async" in line + ] + assert offenders == [], ( + "the generated sync tree still refers to the async one:\n" + + "\n".join(offenders) + ) + + def _async_generator_names() -> set[str]: """Return the name of every ``async def`` in ``_async/`` that yields.""" diff --git a/integration_tests/test_integration_async.py b/integration_tests/test_integration_async.py index 9c8c4f7..d35570d 100644 --- a/integration_tests/test_integration_async.py +++ b/integration_tests/test_integration_async.py @@ -8,10 +8,10 @@ * Concurrent fan-out via :func:`asyncio.gather` (read-only). * OAuth token-acquisition lock contention from many coroutines racing for the very first authenticated call. -* Routing every call through a caller-supplied - real non-blocking I/O on the caller's event loop. +* Real non-blocking I/O on the caller's event loop, with no worker + thread anywhere on the path. * Cancellation of an in-flight awaiting coroutine. -* Exception propagation from a worker thread back to the awaiter. +* Exception propagation from the transport back to the awaiter. The shared configuration loader from :mod:`test_integration` is reused so the same secrets/env layout drives both suites. diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 15aa3cb..01a2c53 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -15,10 +15,9 @@ The package exposes two clients with identical endpoint surfaces: - `glpi_python_client.GlpiClient` — synchronous, blocking client. Use it from scripts, CLI tools, or any code that is not already running inside an event loop. -- `glpi_python_client.AsyncGlpiClient` — asynchronous facade. Each method - is a coroutine that dispatches the underlying blocking call on a worker - thread (`asyncio.to_thread` by default, or a caller-supplied - `concurrent.futures.Executor`). Use it when an event loop is already +- `glpi_python_client.AsyncGlpiClient` — asynchronous client. Each method is + a coroutine performing real non-blocking I/O on the event loop; there is + no worker thread and no executor. Use it when an event loop is already running or when you want concurrent fan-out via `asyncio.gather`. Both clients share the same method names and signatures, including @@ -47,8 +46,9 @@ call `client.close()` (or `await client.close()`) when finished. uploads are needed (`upload_document`). `v1_app_token` is optional. 7. Keep `verify_ssl=True` unless the user explicitly confirms a test or internal endpoint that cannot validate TLS. -8. For the async client only, optionally pass `executor=` a - `concurrent.futures.ThreadPoolExecutor` to bound worker threads. +8. To bound a large async fan-out, wrap the calls in an + `asyncio.Semaphore` on the caller side. There is no `executor=` + argument and no thread pool to size. ## Environment Defaults diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 3f1823e..5845a6a 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -65,7 +65,7 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes") ## Gotchas - `upload_document` raises `RuntimeError` when the v1 session is not configured. Pass `v1_base_url` and `v1_user_token` to the client constructor or `from_env`. -- `upload_document` requires a non-empty `filename` and dispatches the blocking HTTP call through `asyncio.to_thread`; the running event loop is not blocked. +- `upload_document` requires a non-empty `filename`. On the async client the multipart POST is awaited like any other call, so the event loop is not blocked. - `download_document_content` returns `bytes` and raises on non-200 responses. - `mime_type` defaults to `application/octet-stream` when omitted on `upload_document`. - All methods are async; always `await` them. diff --git a/unasync_build.py b/unasync_build.py index c0936cc..dfc122e 100644 --- a/unasync_build.py +++ b/unasync_build.py @@ -22,8 +22,10 @@ name is handled by the hand-written ``_concurrency.py`` twins instead. * It rewrites a **string literal whose entire content** is a substitution key. That is what makes ``__all__ = ["AsyncGlpiClient"]`` generate - correctly. It is narrow: an embedded mention inside a longer string or a - docstring is left alone, so prose is safe. + correctly. It is narrow: an embedded mention inside a longer string is + left alone. That is right for prose but wrong for a *qualified path* + written in prose, so this script repoints those itself -- see + :data:`PROSE_PACKAGE_PREFIX`. * It leaves ``asyncio.gather`` and friends **intact**, which would produce broken sync code. Those live only in ``_concurrency.py``, which is excluded from generation and hand-written on both sides. @@ -85,6 +87,24 @@ } +#: The qualified package prefix, and what it becomes in the generated tree. +#: +#: In an ``import`` statement ``_async`` is its own NAME token, so unasync +#: repoints every intra-tree import for free. Inside a docstring the whole +#: thing is a *single* string token, and substitution only fires when a +#: literal's entire content is a key -- so a cross-reference such as +#: ``:mod:`glpi_python_client._async.clients.api``` sails through untouched +#: and the generated client documents itself in terms of the other tree. +#: +#: Rewriting the qualified prefix afterwards is safe precisely because it is +#: qualified: it names this package and a tree that the generated code must +#: never mention. A bare ``_async`` in prose would be ambiguous; this is not. +#: ``tests/test_unasync_codegen.py`` holds the invariant from the other end, +#: asserting the generated tree contains no ``_async`` at all -- which also +#: fails if someone writes a bare mention this rewrite cannot see. +PROSE_PACKAGE_PREFIX = ("glpi_python_client._async", "glpi_python_client._sync") + + def _source_files() -> list[pathlib.Path]: """Return every ``_async/`` module that should be generated from.""" @@ -95,6 +115,22 @@ def _source_files() -> list[pathlib.Path]: ) +def _repoint_prose(into: pathlib.Path, generated: list[pathlib.Path]) -> None: + """Point qualified package paths in the generated files at the sync tree. + + Runs over the freshly written modules only, and is idempotent: unasync + has already turned the import statements into ``_sync``, so the stale + prefix survives nowhere but inside string literals. + """ + + stale, fresh = PROSE_PACKAGE_PREFIX + for rel in generated: + path = into / rel + text = path.read_text(encoding="utf-8") + if stale in text: + path.write_text(text.replace(stale, fresh), encoding="utf-8") + + def _generate(into: pathlib.Path) -> None: """Run unasync over the async tree, writing the sync tree into ``into``.""" @@ -103,7 +139,9 @@ def _generate(into: pathlib.Path) -> None: todir=str(into), additional_replacements=TOKEN_REPLACEMENTS, ) - unasync.unasync_files([str(p) for p in _source_files()], [rule]) + sources = _source_files() + unasync.unasync_files([str(p) for p in sources], [rule]) + _repoint_prose(into, [p.relative_to(ASYNC_DIR) for p in sources]) # The hand-written twins are never generated. When building into a # scratch directory for --check they must still be carried across, or From e83f743018607c7574a145b0f2c5e7ab237b265f Mon Sep 17 00:00:00 2001 From: baraline Date: Tue, 28 Jul 2026 10:37:12 +0200 Subject: [PATCH 7/9] chore(privacy): scrub instance-specific identifiers from the published package The plugin-fields docs, examples and fixtures were written against a live GLPI instance, so its custom container names and a real ticket id shipped in both the sdist and the wheel. None of it is needed to explain the API. - rename the worked-example container/field to `extrainfo`/`extrainfofield` and the second fixture container to `secondary` - replace the real ticket id with 1234 in the shipped tests - read the integration test's ticket id and container/field names from local secrets (`glpi_fields_ticket_id`, `glpi_fields_container`, `glpi_fields_field`) instead of hardcoding them; the test skips cleanly when they are unset - use a neutral entity fragment in the user-location skill example - exclude docs/superpowers/, the API contract dump and .coverage from the build so gitignore is no longer the only thing keeping them out The container docstring lives in _async/ and was regenerated into _sync/. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user_guide.rst | 4 +- .../_async/clients/api/plugins/_fields.py | 4 +- .../_sync/clients/api/plugins/_fields.py | 4 +- .../models/api_schema/plugins/_fields.py | 4 +- .../plugins/tests/test_fields_schemas.py | 24 +++--- .../tests/api_plugins/test_fields_mixin.py | 85 +++++++++---------- integration_tests/test_integration.py | 43 ++++++---- pyproject.toml | 3 + .../glpi-user-location-provisioning/SKILL.md | 2 +- 9 files changed, 91 insertions(+), 82 deletions(-) diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 90a4d94..9a2fb9f 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -733,8 +733,8 @@ internal container and field names: client.set_ticket_custom_fields( ticket_id, { - "aidelarsolution": { - "aidelarsolutionfield": "

Handled by the NOC shift

", + "extrainfo": { + "extrainfofield": "

Handled by the NOC shift

", } }, ) diff --git a/glpi_python_client/_async/clients/api/plugins/_fields.py b/glpi_python_client/_async/clients/api/plugins/_fields.py index 82f9aaa..616e953 100644 --- a/glpi_python_client/_async/clients/api/plugins/_fields.py +++ b/glpi_python_client/_async/clients/api/plugins/_fields.py @@ -21,8 +21,8 @@ The value itemtype for one container is derived from the container ``name`` field with :func:`_value_itemtype_for`: container -``aidelarsolution`` attached to ``Ticket`` becomes -``PluginFieldsTicketaidelarsolution``. Field column names declared on +``extrainfo`` attached to ``Ticket`` becomes +``PluginFieldsTicketextrainfo``. Field column names declared on :class:`~glpi_python_client.models.api_schema.plugins.GetPluginFieldsField` flow through :attr:`~glpi_python_client.models._base.GlpiModel.extra_payload` on the value rows. diff --git a/glpi_python_client/_sync/clients/api/plugins/_fields.py b/glpi_python_client/_sync/clients/api/plugins/_fields.py index b4970d0..46f1b3d 100644 --- a/glpi_python_client/_sync/clients/api/plugins/_fields.py +++ b/glpi_python_client/_sync/clients/api/plugins/_fields.py @@ -21,8 +21,8 @@ The value itemtype for one container is derived from the container ``name`` field with :func:`_value_itemtype_for`: container -``aidelarsolution`` attached to ``Ticket`` becomes -``PluginFieldsTicketaidelarsolution``. Field column names declared on +``extrainfo`` attached to ``Ticket`` becomes +``PluginFieldsTicketextrainfo``. Field column names declared on :class:`~glpi_python_client.models.api_schema.plugins.GetPluginFieldsField` flow through :attr:`~glpi_python_client.models._base.GlpiModel.extra_payload` on the value rows. diff --git a/glpi_python_client/models/api_schema/plugins/_fields.py b/glpi_python_client/models/api_schema/plugins/_fields.py index f5f5260..8ac682f 100644 --- a/glpi_python_client/models/api_schema/plugins/_fields.py +++ b/glpi_python_client/models/api_schema/plugins/_fields.py @@ -5,7 +5,7 @@ problems, ...). It is configured through two top-level itemtypes: * :class:`GetPluginFieldsContainer` — one container per "tab" or "block" - added to an itemtype (e.g. an ``Aide à la résolution`` tab on + added to an itemtype (e.g. an ``Extra information`` tab on ``Ticket``). * :class:`GetPluginFieldsField` — one user-defined field declaration belonging to a container (the column name, type, default value, ...). @@ -13,7 +13,7 @@ For each container, the plugin creates a dedicated itemtype that stores one row per "item + container" pair. The itemtype name is built as ``PluginFields`` (e.g. -``PluginFieldsTicketaidelarsolution`` for an ``aidelarsolution`` +``PluginFieldsTicketextrainfo`` for an ``extrainfo`` container attached to ``Ticket``). Rows from those itemtypes are modelled by :class:`GetPluginFieldsValueRow` — the actual field columns are dynamic so they flow through the ``extra_payload`` escape hatch on diff --git a/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py b/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py index 15b7c87..4461389 100644 --- a/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py +++ b/glpi_python_client/models/api_schema/plugins/tests/test_fields_schemas.py @@ -15,8 +15,8 @@ def test_get_container_full_payload() -> None: payload = { "id": 10, - "name": "aidelarsolution", - "label": "Aide à la résolution", + "name": "extrainfo", + "label": "Extra information", "itemtypes": '["Ticket"]', "type": "tab", "subtype": None, @@ -26,7 +26,7 @@ def test_get_container_full_payload() -> None: "links": [{"rel": "Entity", "href": "https://example/Entity/0"}], } container = GetPluginFieldsContainer.model_validate(payload) - assert container.name == "aidelarsolution" + assert container.name == "extrainfo" # The undocumented ``links`` key flows through extra_payload. assert "links" in container.extra_payload @@ -36,8 +36,8 @@ def test_get_field_full_payload() -> None: payload = { "id": 11, - "name": "aidelarsolutionfield", - "label": "Aide à la résolution", + "name": "extrainfofield", + "label": "Extra information", "type": "richtext", "plugin_fields_containers_id": 10, "ranking": 1, @@ -59,24 +59,24 @@ def test_value_row_dynamic_columns_in_extra_payload() -> None: row = GetPluginFieldsValueRow.model_validate( { "id": 1, - "items_id": 62571, + "items_id": 1234, "itemtype": "Ticket", "plugin_fields_containers_id": 10, "entities_id": 0, - "aidelarsolutionfield": "

test

", + "extrainfofield": "

test

", } ) - assert row.items_id == 62571 - assert row.extra_payload["aidelarsolutionfield"] == "

test

" + assert row.items_id == 1234 + assert row.extra_payload["extrainfofield"] == "

test

" def test_post_value_row_carries_dynamic_columns() -> None: """The POST body accepts dynamic field columns via ``extra_payload``.""" body = PostPluginFieldsValueRow( - items_id=62571, + items_id=1234, itemtype="Ticket", plugin_fields_containers_id=10, - extra_payload={"aidelarsolutionfield": "

x

"}, + extra_payload={"extrainfofield": "

x

"}, ) - assert body.extra_payload == {"aidelarsolutionfield": "

x

"} + assert body.extra_payload == {"extrainfofield": "

x

"} diff --git a/glpi_python_client/tests/api_plugins/test_fields_mixin.py b/glpi_python_client/tests/api_plugins/test_fields_mixin.py index 5390152..0115f72 100644 --- a/glpi_python_client/tests/api_plugins/test_fields_mixin.py +++ b/glpi_python_client/tests/api_plugins/test_fields_mixin.py @@ -62,10 +62,7 @@ def client() -> GlpiClient: def test_value_itemtype_naming() -> None: """The value itemtype is built from parent type + lowercase container.""" - assert ( - _value_itemtype_for("Ticket", "aidelarsolution") - == "PluginFieldsTicketaidelarsolution" - ) + assert _value_itemtype_for("Ticket", "extrainfo") == "PluginFieldsTicketextrainfo" # Mixed-case names get normalised to lowercase to match the v1 routes. assert ( _value_itemtype_for("Ticket", "MyContainer") == "PluginFieldsTicketmycontainer" @@ -169,19 +166,19 @@ def test_list_item_plugin_field_rows_hits_subresource(client: GlpiClient) -> Non [ { "id": 1, - "items_id": 62571, + "items_id": 1234, "itemtype": "Ticket", "plugin_fields_containers_id": 10, "entities_id": 0, - "aidelarsolutionfield": "

test

", + "extrainfofield": "

test

", } ] ] ) client._v1 = fake # type: ignore[assignment] - rows = client.list_item_plugin_field_rows("Ticket", 62571, "aidelarsolution") - assert rows[0].extra_payload == {"aidelarsolutionfield": "

test

"} - assert fake.calls[0]["path"] == "Ticket/62571/PluginFieldsTicketaidelarsolution" + rows = client.list_item_plugin_field_rows("Ticket", 1234, "extrainfo") + assert rows[0].extra_payload == {"extrainfofield": "

test

"} + assert fake.calls[0]["path"] == "Ticket/1234/PluginFieldsTicketextrainfo" def test_create_item_plugin_field_row_returns_new_id(client: GlpiClient) -> None: @@ -193,20 +190,20 @@ def test_create_item_plugin_field_row_returns_new_id(client: GlpiClient) -> None itemtype="Ticket", items_id=99, container_id=10, - container_name="aidelarsolution", - values={"aidelarsolutionfield": "

x

"}, + container_name="extrainfo", + values={"extrainfofield": "

x

"}, entities_id=3, ) assert row_id == 7 call = fake.calls[0] assert call["method"] == "POST" - assert call["path"] == "PluginFieldsTicketaidelarsolution" + assert call["path"] == "PluginFieldsTicketextrainfo" assert call["json_body"] == { "input": { "items_id": 99, "itemtype": "Ticket", "plugin_fields_containers_id": 10, - "aidelarsolutionfield": "

x

", + "extrainfofield": "

x

", "entities_id": 3, } } @@ -219,16 +216,14 @@ def test_update_item_plugin_field_row_puts_partial_body(client: GlpiClient) -> N client._v1 = fake # type: ignore[assignment] client.update_item_plugin_field_row( itemtype="Ticket", - container_name="aidelarsolution", + container_name="extrainfo", row_id=1, - values={"aidelarsolutionfield": "

updated

"}, + values={"extrainfofield": "

updated

"}, ) call = fake.calls[0] assert call["method"] == "PUT" - assert call["path"] == "PluginFieldsTicketaidelarsolution/1" - assert call["json_body"] == { - "input": {"id": 1, "aidelarsolutionfield": "

updated

"} - } + assert call["path"] == "PluginFieldsTicketextrainfo/1" + assert call["json_body"] == {"input": {"id": 1, "extrainfofield": "

updated

"}} def test_get_ticket_custom_fields_aggregates_containers(client: GlpiClient) -> None: @@ -238,28 +233,28 @@ def test_get_ticket_custom_fields_aggregates_containers(client: GlpiClient) -> N responses=[ # 1. list_plugin_fields_containers(Ticket) [ - {"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'}, - {"id": 2, "name": "sige", "itemtypes": '["Ticket"]'}, + {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}, + {"id": 2, "name": "secondary", "itemtypes": '["Ticket"]'}, {"id": 3, "name": "ignored", "itemtypes": '["Computer"]'}, ], - # 2. list_item_plugin_field_rows aidelarsolution + # 2. list_item_plugin_field_rows extrainfo [ { "id": 1, - "items_id": 62571, + "items_id": 1234, "itemtype": "Ticket", "plugin_fields_containers_id": 10, "entities_id": 0, - "aidelarsolutionfield": "

test

", + "extrainfofield": "

test

", } ], - # 3. list_item_plugin_field_rows sige -> no row yet + # 3. list_item_plugin_field_rows secondary -> no row yet [], ] ) client._v1 = fake # type: ignore[assignment] - result = client.get_ticket_custom_fields(62571) - assert result == {"aidelarsolution": {"aidelarsolutionfield": "

test

"}} + result = client.get_ticket_custom_fields(1234) + assert result == {"extrainfo": {"extrainfofield": "

test

"}} def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> None: @@ -269,13 +264,13 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No responses=[ # list containers [ - {"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'}, + {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}, ], # list fields for container 10 [ { "id": 11, - "name": "aidelarsolutionfield", + "name": "extrainfofield", "plugin_fields_containers_id": 10, } ], @@ -283,7 +278,7 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No [ { "id": 1, - "items_id": 62571, + "items_id": 1234, "itemtype": "Ticket", "plugin_fields_containers_id": 10, } @@ -294,14 +289,12 @@ def test_set_ticket_custom_fields_updates_existing_row(client: GlpiClient) -> No ) client._v1 = fake # type: ignore[assignment] client.set_ticket_custom_fields( - 62571, {"aidelarsolution": {"aidelarsolutionfield": "

new

"}} + 1234, {"extrainfo": {"extrainfofield": "

new

"}} ) methods = [c["method"] for c in fake.calls] assert methods == ["GET", "GET", "GET", "PUT"] put = fake.calls[-1] - assert put["json_body"] == { - "input": {"id": 1, "aidelarsolutionfield": "

new

"} - } + assert put["json_body"] == {"input": {"id": 1, "extrainfofield": "

new

"}} def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> None: @@ -310,12 +303,12 @@ def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> No fake = _FakeV1( responses=[ [ - {"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'}, + {"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}, ], [ { "id": 11, - "name": "aidelarsolutionfield", + "name": "extrainfofield", "plugin_fields_containers_id": 10, } ], @@ -325,17 +318,17 @@ def test_set_ticket_custom_fields_creates_when_missing(client: GlpiClient) -> No ) client._v1 = fake # type: ignore[assignment] client.set_ticket_custom_fields( - 62571, {"aidelarsolution": {"aidelarsolutionfield": "

new

"}} + 1234, {"extrainfo": {"extrainfofield": "

new

"}} ) methods = [c["method"] for c in fake.calls] assert methods == ["GET", "GET", "GET", "POST"] post = fake.calls[-1] assert post["json_body"] == { "input": { - "items_id": 62571, + "items_id": 1234, "itemtype": "Ticket", "plugin_fields_containers_id": 10, - "aidelarsolutionfield": "

new

", + "extrainfofield": "

new

", } } @@ -352,7 +345,7 @@ def test_set_ticket_custom_fields_rejects_unknown_container(client: GlpiClient) with pytest.raises( GlpiValidationError, match="Unknown plugin-fields container" ) as excinfo: - client.set_ticket_custom_fields(62571, {"typo": {"any": "value"}}) + client.set_ticket_custom_fields(1234, {"typo": {"any": "value"}}) # No mutation was sent. assert all(c["method"] == "GET" for c in fake.calls) assert isinstance(excinfo.value, ValueError) @@ -371,11 +364,11 @@ def test_set_ticket_custom_fields_rejects_container_without_id( working. """ - fake = _FakeV1(responses=[[{"name": "aidelarsolution", "itemtypes": '["Ticket"]'}]]) + fake = _FakeV1(responses=[[{"name": "extrainfo", "itemtypes": '["Ticket"]'}]]) client._v1 = fake # type: ignore[assignment] with pytest.raises(GlpiProtocolError, match="has no id") as excinfo: client.set_ticket_custom_fields( - 62571, {"aidelarsolution": {"aidelarsolutionfield": "value"}} + 1234, {"extrainfo": {"extrainfofield": "value"}} ) assert all(c["method"] == "GET" for c in fake.calls) assert isinstance(excinfo.value, ValueError) @@ -390,11 +383,11 @@ def test_set_ticket_custom_fields_rejects_unknown_field(client: GlpiClient) -> N fake = _FakeV1( responses=[ - [{"id": 10, "name": "aidelarsolution", "itemtypes": '["Ticket"]'}], + [{"id": 10, "name": "extrainfo", "itemtypes": '["Ticket"]'}], [ { "id": 11, - "name": "aidelarsolutionfield", + "name": "extrainfofield", "plugin_fields_containers_id": 10, } ], @@ -402,7 +395,7 @@ def test_set_ticket_custom_fields_rejects_unknown_field(client: GlpiClient) -> N ) client._v1 = fake # type: ignore[assignment] with pytest.raises(GlpiValidationError, match="Unknown field") as excinfo: - client.set_ticket_custom_fields(62571, {"aidelarsolution": {"typo": "value"}}) + client.set_ticket_custom_fields(1234, {"extrainfo": {"typo": "value"}}) assert isinstance(excinfo.value, ValueError) @@ -413,5 +406,5 @@ def test_set_ticket_custom_fields_with_empty_mapping_is_noop( fake = _FakeV1(responses=[]) client._v1 = fake # type: ignore[assignment] - client.set_ticket_custom_fields(62571, {}) + client.set_ticket_custom_fields(1234, {}) assert fake.calls == [] diff --git a/integration_tests/test_integration.py b/integration_tests/test_integration.py index ea0de46..8c599b8 100644 --- a/integration_tests/test_integration.py +++ b/integration_tests/test_integration.py @@ -670,13 +670,18 @@ def test_get_user_activity_raises_without_identifier(client: GlpiClient) -> None # --------------------------------------------------------------------------- # GLPI Fields plugin (legacy v1 endpoints) # -# These tests target the live preprod ticket #62571, which carries an -# ``aidelarsolution`` custom container set up via the GLPI Fields plugin. -# When the plugin is not installed the tests skip cleanly so the suite stays -# portable across instances. +# These tests target a ticket carrying a custom container set up via the GLPI +# Fields plugin. Both the ticket id and the container/field names are +# instance-specific, so they are read from local secrets rather than hardcoded. +# When the plugin or the configuration is absent the tests skip cleanly so the +# suite stays portable across instances. # --------------------------------------------------------------------------- -_FIELDS_TEST_TICKET_ID = 62571 +_FIELDS_TEST_TICKET_ID = _parse_int( + _read_value("glpi_fields_ticket_id", "GLPI_FIELDS_TICKET_ID") +) +_FIELDS_CONTAINER_NAME = _read_value("glpi_fields_container", "GLPI_FIELDS_CONTAINER") +_FIELDS_FIELD_NAME = _read_value("glpi_fields_field", "GLPI_FIELDS_FIELD") # GLPI answers 400 with this marker when the itemtype in the URL is not a # known CommonDBTM subclass -- which is what an uninstalled plugin looks @@ -732,38 +737,45 @@ def test_plugin_fields_containers_discovery( def test_get_ticket_custom_fields_round_trip_on_known_ticket( client: GlpiClient, fields_containers: list[GetPluginFieldsContainer] ) -> None: - """Round-trip the ``aidelarsolution`` custom field on ticket 62571. + """Round-trip the configured custom field on the configured ticket. The original value is captured, replaced by a timestamped probe value, read back, and finally restored so the test is net-zero. """ + if ( + _FIELDS_TEST_TICKET_ID is None + or _FIELDS_CONTAINER_NAME is None + or _FIELDS_FIELD_NAME is None + ): + pytest.skip("Fields plugin round-trip target not configured in secrets") + containers = fields_containers - container = next((c for c in containers if c.name == "aidelarsolution"), None) + container = next((c for c in containers if c.name == _FIELDS_CONTAINER_NAME), None) if container is None: - pytest.skip("'aidelarsolution' container missing on this instance") + pytest.skip("configured container missing on this instance") assert container.id is not None fields = client.list_plugin_fields_fields(container_id=container.id) - field = next((f for f in fields if f.name == "aidelarsolutionfield"), None) + field = next((f for f in fields if f.name == _FIELDS_FIELD_NAME), None) if field is None: - pytest.skip("'aidelarsolutionfield' missing in container") + pytest.skip("configured field missing in container") before = client.get_ticket_custom_fields(_FIELDS_TEST_TICKET_ID) - original = before.get("aidelarsolution", {}).get("aidelarsolutionfield") + original = before.get(_FIELDS_CONTAINER_NAME, {}).get(_FIELDS_FIELD_NAME) probe_value = f"

integration probe {_suffix()}

" try: client.set_ticket_custom_fields( _FIELDS_TEST_TICKET_ID, - {"aidelarsolution": {"aidelarsolutionfield": probe_value}}, + {_FIELDS_CONTAINER_NAME: {_FIELDS_FIELD_NAME: probe_value}}, ) after = client.get_ticket_custom_fields(_FIELDS_TEST_TICKET_ID) - assert after["aidelarsolution"]["aidelarsolutionfield"] == probe_value + assert after[_FIELDS_CONTAINER_NAME][_FIELDS_FIELD_NAME] == probe_value finally: if original is not None: client.set_ticket_custom_fields( _FIELDS_TEST_TICKET_ID, - {"aidelarsolution": {"aidelarsolutionfield": original}}, + {_FIELDS_CONTAINER_NAME: {_FIELDS_FIELD_NAME: original}}, ) @@ -773,8 +785,9 @@ def test_set_ticket_custom_fields_rejects_unknown_container( ) -> None: """Writing to a non-existent container raises before any HTTP call.""" + # The ticket id is irrelevant here: the guard fires before any HTTP call. with pytest.raises(ValueError, match="Unknown plugin-fields container"): client.set_ticket_custom_fields( - _FIELDS_TEST_TICKET_ID, + 1, {"does-not-exist-xyz": {"any_field": "value"}}, ) diff --git a/pyproject.toml b/pyproject.toml index 87bdaf0..1276b6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,13 @@ exclude = [ "dist/", "*.egg-info/", "docs/_build/", + "docs/superpowers/", + "docs/glpi_api_contract.json", ".venv/", "venv/", ".env", ".env.*", + ".coverage", "secrets/", "integration_tests/", ] diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 5afd2f0..43525ab 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -62,7 +62,7 @@ location_id = ( Look entities up by name fragment: ```python -entities = await client.search_entities("name=like=*novahe*", limit=10) +entities = await client.search_entities("name=like=*acme*", limit=10) for entity in entities: print(entity.id, entity.name, entity.completename) ``` From d972f1d33ee67b624f22604e7c25fabad1e29e86 Mon Sep 17 00:00:00 2001 From: baraline Date: Tue, 28 Jul 2026 10:58:46 +0200 Subject: [PATCH 8/9] Add sniffio as dep --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ pyproject.toml | 10 ++++++++++ 2 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d40be..9d28153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,6 +241,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). module and its `remote_error_message` helper are removed. It had no library call sites, and `reraise=True` leaves it nothing to unwrap. +### Performance + +- **`sniffio` is now a dependency, and the async client is ~2.6x faster at + wide fan-out because of it.** `httpcore` decides whether it is running + under asyncio or trio by probing for `sniffio` on every async request, + falling back to `"asyncio"` when the import fails. Nothing in the + dependency chain required it — `httpx` pulls in `anyio`, and `anyio` 4.14 + dropped `sniffio` — so a fresh install had no `sniffio`, and because + Python never caches a failed import, every single request re-walked + `sys.path` doing filesystem stats. Measured against a local server with + 50 ms latency, a fan-out of 128 took **3354 ms without `sniffio` and + 1304 ms with it**. `pip check` reports no broken requirements either way, + which is why this went unnoticed: nothing declares the package, nothing + imports it, and the only symptom is that every request is slower. + +- **Bounding a wide fan-out is now a documented requirement, not a + suggestion.** `httpcore` rescans its entire connection pool on every + request assignment, calling `has_expired()` per connection — profiled at + 9040 such calls for a 64-request fan-out. The cost is quadratic in the + width of the fan-out and it saturates the event loop, so server-observed + concurrency *falls* as the fan-out widens. Raising `httpx.Limits` does not + help. At a fan-out of 16 against a 50 ms server, an unbounded + `gather` took 350 ms while the same work capped at 16 with an + `asyncio.Semaphore` took 108 ms. See "Bounding concurrency" in the user + guide. This is a property of `httpx` 0.28 / `httpcore` 1.0.9, which are + the current releases; there is no version to upgrade to. + ### Documentation - **The documentation still described the deleted bridge**, in the places diff --git a/pyproject.toml b/pyproject.toml index 1276b6c..ee48995 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,16 @@ dependencies = [ "markdown>=3.6", "markdownify>=0.13", "pydantic>=2.8", + # Never imported by this package, and deliberately so. httpcore decides + # whether it is running under asyncio or trio by probing for sniffio on + # every async request (`try: import sniffio / except ImportError`). + # Nothing else in the chain requires it -- anyio 4.14 dropped the + # dependency and httpx does not replace it -- and Python never caches a + # failed import, so when sniffio is absent each request re-walks + # sys.path instead. Measured at a fan-out of 128 against a 50ms server: + # 3354ms without it, 1304ms with. `pip check` reports no broken + # requirements either way, so this pin is the only thing holding the fix. + "sniffio>=1.3", "tenacity>=8.2", "typing-extensions>=4.7; python_version < '3.11'", ] From 866083793af4bb38398857b3b18298c562ca52a2 Mon Sep 17 00:00:00 2001 From: baraline Date: Tue, 28 Jul 2026 11:51:04 +0200 Subject: [PATCH 9/9] Update skills and add a test for skill references --- .../tests/test_skill_references.py | 186 ++++++++++++++++++ skills/README.md | 13 +- skills/glpi-client-setup/SKILL.md | 23 ++- skills/glpi-document-workflow/SKILL.md | 6 +- skills/glpi-reporting-and-context/SKILL.md | 23 ++- skills/glpi-team-members/SKILL.md | 6 +- skills/glpi-ticket-timeline/SKILL.md | 6 +- skills/glpi-ticket-workflow/SKILL.md | 8 +- .../glpi-user-location-provisioning/SKILL.md | 6 +- 9 files changed, 247 insertions(+), 30 deletions(-) create mode 100644 glpi_python_client/tests/test_skill_references.py diff --git a/glpi_python_client/tests/test_skill_references.py b/glpi_python_client/tests/test_skill_references.py new file mode 100644 index 0000000..f354c90 --- /dev/null +++ b/glpi_python_client/tests/test_skill_references.py @@ -0,0 +1,186 @@ +"""Every method the bundled skills name exists on both clients. + +``skills/`` is instructions for an agent, so a wrong name there is not a +typo in prose -- it is a wrong API taught to whatever reads it, and the +resulting code fails at the caller rather than here. Nothing else in the +project looks at these files: they are not imported, not built by Sphinx, +and not touched by mypy or ruff. Between them and the library there is no +gate at all. + +That gap has already cost us. The move to a generated sync tree left +``skills/README.md`` instructing readers to write ``async with +GlpiClient(...)`` -- but ``GlpiClient`` is the *synchronous* client, so +the snippet raises ``AttributeError``. Six ``SKILL.md`` files described +"the asynchronous ``glpi_python_client.GlpiClient``" in their frontmatter +``description``, which is the one field an agent reads before deciding to +open the file at all. And ``glpi-client-setup`` credited the async client +with a ``threading.Lock`` around OAuth -- the exact primitive +``_concurrency.py`` documents as a deadlock on that surface, because the +lock is held across an ``await``. + +None of that broke a test, a build, or a type check. +""" + +from __future__ import annotations + +import pathlib +import re + +import pytest + +from glpi_python_client import AsyncGlpiClient, GlpiClient + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_SKILLS_DIR = _REPO_ROOT / "skills" + +# ``skills/`` ships in the sdist but not in the wheel, so an installed +# package legitimately has no copy to check. +pytestmark = pytest.mark.skipif( + not _SKILLS_DIR.is_dir(), reason="skills/ is source-tree material, not in the wheel" +) + +#: A method call on a client in a snippet. Both spellings appear: the +#: skills bind the client to ``client`` or, via ``from_env``, to ``glpi``. +_CALL = re.compile(r"\b(?:client|glpi)\.(\w+)\s*\(") + +#: Claims that were true of the retired thread-pool bridge and are false +#: now. The last two are subtler than the rest: naming ``GlpiClient`` as +#: asynchronous inverts the two classes, and crediting the async client +#: with a ``threading.Lock`` names the primitive that deadlocks there. +_STALE = ( + r"executor=", + r"to_thread", + r"worker thread", + r"async with GlpiClient", + r"asynchronous glpi_python_client\.GlpiClient\b", + r"threading\.Lock[^\n]*AsyncGlpiClient", +) + +#: The skills *deny* the bridge's machinery on purpose -- "there is no +#: ``executor=`` argument and no thread pool to size". Denying a thing is +#: the opposite of claiming it, so a hit preceded by a negation is correct +#: prose. Without this the check reports its own corrections as failures. +_NEGATED = re.compile(r"\b(?:no|not|never|retired|removed|deleted)\b[^.]{0,60}$") + + +def _skill_files() -> list[pathlib.Path]: + """Return every skill document, including the index README.""" + + return [*sorted(_SKILLS_DIR.glob("*/SKILL.md")), _SKILLS_DIR / "README.md"] + + +def _frontmatter_name(text: str) -> str | None: + """Return the ``name:`` field of a skill's frontmatter, if it has one. + + Parsed by hand rather than with a YAML library: the frontmatter is a + handful of flat scalars, and this keeps the test suite from gaining a + dependency for one field. + """ + + if not text.startswith("---"): + return None + _, _, rest = text.partition("---\n") + front, _, _ = rest.partition("\n---") + match = re.search(r"^name:\s*(\S+)\s*$", front, re.MULTILINE) + return match.group(1) if match else None + + +def _stale_claims(text: str) -> list[tuple[int, str]]: + """Return ``(lineno, matched text)`` for each unnegated stale claim.""" + + hits: list[tuple[int, str]] = [] + for pattern in _STALE: + for match in re.finditer(pattern, text, re.IGNORECASE): + preceding = text[max(0, match.start() - 70) : match.start()] + if _NEGATED.search(preceding.replace("\n", " ")): + continue + hits.append((text[: match.start()].count("\n") + 1, match.group(0))) + return hits + + +def test_the_scan_finds_skills_and_can_tell_claims_apart() -> None: + """Positive control: the scans are not vacuous and do discriminate. + + Without this, a regex that stopped matching would leave the checks + below passing forever without reading anything. + """ + + files = _skill_files() + assert len(files) > 5, f"only {len(files)} skill files found -- layout changed?" + + calls = sum(len(_CALL.findall(p.read_text(encoding="utf-8-sig"))) for p in files) + assert calls > 20, f"only {calls} client calls found -- regex broken?" + + assert _stale_claims("Use `async with GlpiClient(...)` and await everything.") + assert _stale_claims("Pass executor= to size the pool.") + # The negation carve-out has to hold, or the check fails on the fix. + assert not _stale_claims("There is no `executor=` argument and no thread pool.") + + +def test_no_skill_starts_with_a_byte_order_mark() -> None: + """A BOM hides the frontmatter from anything that reads the file. + + Frontmatter is recognised by the file *starting* with ``---``. Three + bytes of UTF-8 BOM in front of it mean it does not, so a strict reader + sees a plain document with no ``name`` and no ``description`` -- and + the description is what an agent uses to decide whether the skill is + relevant at all. Six of these files carried one, invisibly, which is + also why this test reads them as ``utf-8-sig``: tolerant parsing must + not be what stops anyone noticing. + """ + + offenders = [ + path.relative_to(_REPO_ROOT).as_posix() + for path in sorted(_SKILLS_DIR.rglob("*.md")) + if path.read_bytes().startswith(b"\xef\xbb\xbf") + ] + assert offenders == [], ( + "these skill files start with a UTF-8 BOM, which hides their " + "frontmatter:\n" + "\n".join(offenders) + ) + + +def test_every_skill_name_matches_its_directory() -> None: + """A skill is addressed by directory; a mismatched ``name`` breaks it.""" + + offenders: list[str] = [] + for path in sorted(_SKILLS_DIR.glob("*/SKILL.md")): + declared = _frontmatter_name(path.read_text(encoding="utf-8-sig")) + if declared != path.parent.name: + offenders.append(f"{path.parent.name}: frontmatter name is {declared!r}") + assert offenders == [], "skill name does not match its directory:\n" + "\n".join( + offenders + ) + + +def test_every_method_named_in_a_skill_exists_on_both_clients() -> None: + """No skill teaches a method that was renamed or removed. + + Checked against *both* classes, because every skill tells the reader + its snippets work on the other one after dropping ``await``. + """ + + offenders: list[str] = [] + for path in _skill_files(): + rel = path.relative_to(_REPO_ROOT).as_posix() + for method in sorted(set(_CALL.findall(path.read_text(encoding="utf-8-sig")))): + for client in (GlpiClient, AsyncGlpiClient): + if not hasattr(client, method): + offenders.append(f"{rel}: {client.__name__} has no {method!r}") + assert offenders == [], ( + "these skills name methods that do not exist:\n" + "\n".join(offenders) + ) + + +def test_no_skill_describes_the_retired_thread_pool_bridge() -> None: + """No skill still explains the async client as a thread-pool wrapper.""" + + offenders = [ + f"{path.relative_to(_REPO_ROOT).as_posix()}:{lineno}: {claim!r}" + for path in _skill_files() + for lineno, claim in _stale_claims(path.read_text(encoding="utf-8-sig")) + ] + assert offenders == [], ( + "these skills describe machinery that no longer exists:\n" + + "\n".join(offenders) + ) diff --git a/skills/README.md b/skills/README.md index 36f150e..83046c7 100644 --- a/skills/README.md +++ b/skills/README.md @@ -8,7 +8,7 @@ These skills are source-tree project material. They are included in source distr | Skill | Use when the agent needs to | Main public API | | --- | --- | --- | -| `glpi-client-setup` | Build and configure an authenticated client | `GlpiClient`, `GlpiClient.from_env()` | +| `glpi-client-setup` | Build and configure an authenticated client | `GlpiClient`, `AsyncGlpiClient`, `.from_env()` | | `glpi-ticket-workflow` | Search, fetch, create, update, or delete tickets | `GetTicket`, `PostTicket`, `PatchTicket`, `DeleteTicket` | | `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete) | | `glpi-document-workflow` | Manage document metadata, upload binary content, download binaries | `GetDocument`, `PostDocument`, `PatchDocument`, `DeleteDocument` | @@ -16,4 +16,13 @@ These skills are source-tree project material. They are included in source distr | `glpi-reporting-and-context` | Aggregate ticket statistics, aggregate task durations, or load one ticket context bundle | `GlpiClient`, `GlpiTicketContext`, public enums | | `glpi-team-members` | List, add, or remove ticket team members | `GetTeamMember`, `PostTeamMember` | -The whole client is asynchronous: use `async with GlpiClient(...)` and `await` every method. +## Sync and async + +The package ships two clients with identical endpoint surfaces: + +- `GlpiClient` — synchronous. `with GlpiClient(...) as client`, no `await`. +- `AsyncGlpiClient` — asynchronous, performing real non-blocking I/O. `async with AsyncGlpiClient(...) as client`, `await` every method. + +Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. The snippets in each skill are written against `AsyncGlpiClient`; every skill opens with a note on how to read them for the synchronous client. + +When fanning out concurrently on the async client, bound the fan-out with an `asyncio.Semaphore` — see `glpi-client-setup`. An unbounded fan-out is slower, not faster. diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 01a2c53..59092dd 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -18,7 +18,9 @@ The package exposes two clients with identical endpoint surfaces: - `glpi_python_client.AsyncGlpiClient` — asynchronous client. Each method is a coroutine performing real non-blocking I/O on the event loop; there is no worker thread and no executor. Use it when an event loop is already - running or when you want concurrent fan-out via `asyncio.gather`. + running or when you want concurrent fan-out via `asyncio.gather` — + bounded, see step 8. Unlike the retired thread-pool bridge, cancelling + a call here actually releases its capacity, so timeouts work. Both clients share the same method names and signatures, including `from_env`, OAuth handling, retry behaviour, and the optional v1 @@ -46,9 +48,13 @@ call `client.close()` (or `await client.close()`) when finished. uploads are needed (`upload_document`). `v1_app_token` is optional. 7. Keep `verify_ssl=True` unless the user explicitly confirms a test or internal endpoint that cannot validate TLS. -8. To bound a large async fan-out, wrap the calls in an - `asyncio.Semaphore` on the caller side. There is no `executor=` - argument and no thread pool to size. +8. Bound any large async fan-out with an `asyncio.Semaphore` on the + caller side. This is not just tidiness: the underlying HTTP pool + rescans itself on every request assignment, so an unbounded fan-out + saturates the event loop and gets *slower* as it widens — measured + against a 50 ms server, 16 concurrent calls took 350 ms unbounded and + 108 ms capped at 16. There is no `executor=` argument and no thread + pool to size. ## Environment Defaults @@ -152,6 +158,9 @@ with GlpiClient.from_env( document-upload fallback. - Closing the client matters because it owns one or two HTTP sessions plus an OAuth token manager. Prefer the context-manager form. -- A shared `threading.Lock` serialises OAuth token acquisition, so it - is safe to launch concurrent `asyncio.gather` fan-outs on - `AsyncGlpiClient` even before the token has been fetched once. +- Concurrent callers cannot stampede the token endpoint: the client + holds a lock around OAuth acquisition, so it is safe to launch a + fan-out on `AsyncGlpiClient` before the token has ever been fetched. + The primitive differs per surface — an `asyncio.Lock` on the async + client, a `threading.Lock` on the synchronous one — which is why a + synchronous client is the one safe to share across threads. diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 5845a6a..d87b5b4 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -1,11 +1,11 @@ ---- +--- name: glpi-document-workflow -description: "Manage GLPI document metadata, upload binary content via the legacy v1 fallback, download document binaries, and link documents to a ticket timeline with the asynchronous glpi_python_client.GlpiClient and the GetDocument/PostDocument/PatchDocument/DeleteDocument models. Use for ticket attachments, document binary content, document metadata, or saving downloaded files." +description: "Manage GLPI document metadata, upload binary content via the legacy v1 fallback, download document binaries, and link documents to a ticket timeline with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetDocument/PostDocument/PatchDocument/DeleteDocument models. Use for ticket attachments, document binary content, document metadata, or saving downloaded files." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." metadata: package: glpi-python-client - version: "0.3.0" + version: "0.4.0" --- # GLPI Document Workflow diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 2a61408..946a57d 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -1,11 +1,11 @@ ---- +--- name: glpi-reporting-and-context -description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the asynchronous glpi_python_client.GlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user/entity/ticket, per-user activity reports, batch-streamed pagination of search results, or one-call ticket context retrieval bundling tickets with timeline records." +description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user/entity/ticket, per-user activity reports, batch-streamed pagination of search results, or one-call ticket context retrieval bundling tickets with timeline records." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: package: glpi-python-client - version: "0.3.0" + version: "0.4.0" --- # GLPI Reporting And Context @@ -13,7 +13,7 @@ metadata: Custom helpers on `GlpiClient` build on top of the contract-aligned API mixins: -- `get_ticket_context(ticket_id)` returns one `GlpiTicketContext` bundling the primary ticket together with its tasks, followups, solutions, and timeline document links. The five underlying calls run concurrently via `asyncio.gather`. +- `get_ticket_context(ticket_id)` returns one `GlpiTicketContext` bundling the primary ticket together with its tasks, followups, solutions, and timeline document links. The five underlying calls are independent and are issued through the library's internal `gather` helper, so they fan out concurrently on `AsyncGlpiClient` and run one after another on `GlpiClient`. Expect the synchronous call to take roughly five round trips. - `get_ticket_statistics(...)` returns ticket counts grouped by entity, status, priority, and type over an ISO date window applied to GLPI `date_creation`. Accepts `entity_id`, `entity_name` (substring match resolved via `search_entities`), and `extra_filter` (raw RSQL AND-joined with the window). - `get_task_statistics(ticket_ids)` returns task duration totals grouped by user and ticket for a caller-supplied list of ticket IDs. - `get_task_durations(...)` is a higher-level helper that internally iterates `iter_search_tickets` with a date/entity/user filter, computes per-user and per-entity duration totals, and optionally returns a flat per-task list when `return_task_details=True`. @@ -24,7 +24,7 @@ Returned identifiers are raw GLPI numeric values; resolve them with the appropri ## Procedure -1. Create a `GlpiClient` with the correct entity/profile scope. +1. Create a client (`GlpiClient` or `AsyncGlpiClient`) with the correct entity/profile scope. 2. For one ticket, call `await client.get_ticket_context(ticket_id)` and read `bundle.ticket`, `bundle.tasks`, `bundle.followups`, `bundle.solutions`, and `bundle.documents`. 3. For ticket counts, call `await client.get_ticket_statistics(start_date=..., end_date=..., default_days=..., entity_id=..., entity_name=..., extra_filter=...)`. All keyword arguments are optional; the default window is the last 30 days ending today. 4. For task duration totals on a known ticket list, call `await client.get_task_statistics(ticket_ids)`. For an end-to-end "duration over a window with filters" report, call `await client.get_task_durations(...)` instead; it gathers the ticket IDs internally. @@ -103,6 +103,19 @@ print(f"processed {total} tickets") ## Gotchas - All helpers shown above are async on `AsyncGlpiClient`; always `await` them. The `iter_search_*` helpers are async **generators** -- use `async for`, not `await`. +- **Bound any fan-out you build on top of these helpers.** Calling `get_ticket_context` for every ticket in a batch with a bare `asyncio.gather` gets *slower* as the batch grows: the underlying HTTP pool rescans itself on every request assignment, so a wide fan-out saturates the event loop and the observed concurrency falls. Cap it instead: + + ```python + gate = asyncio.Semaphore(16) + + async def one(ticket_id): + async with gate: + return await client.get_ticket_context(ticket_id) + + contexts = await asyncio.gather(*(one(t.id) for t in batch)) + ``` + + Measured against a 50 ms server, a fan-out of 16 took 350 ms unbounded and 108 ms capped at 16. This is a property of the HTTP layer, not of this library, and there is no version to upgrade to. - `get_ticket_statistics`, `get_task_durations`, and `get_user_activity` validate their date window locally and raise `ValueError` when `default_days < 1` or `start_date > end_date`. The window is applied to `date_creation` server-side. - `get_task_statistics(ticket_ids=[])` returns zeroed totals without any HTTP call. `get_task_durations` likewise returns zeroed totals when no tickets match the filter, and short-circuits with zeros when `entity_name` resolves to no entities. - `get_user_activity` raises `ValueError` when no identifier is supplied and when the criteria match no users. Multiple users with the same `f"{firstname} {realname}"` display key are merged into one bucket. diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 545eebb..871e832 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -1,11 +1,11 @@ ---- +--- name: glpi-team-members -description: "List, add, and remove GLPI ticket team members with the asynchronous glpi_python_client.GlpiClient and the GetTeamMember/PostTeamMember models. Use when assigning users or groups to tickets, inspecting ticket teams, or removing GLPI ticket participants." +description: "List, add, and remove GLPI ticket team members with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetTeamMember/PostTeamMember models. Use when assigning users or groups to tickets, inspecting ticket teams, or removing GLPI ticket participants." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.3.0" + version: "0.4.0" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 8bad81d..7d2e806 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -1,11 +1,11 @@ ---- +--- name: glpi-ticket-timeline -description: "Read GLPI ticket timeline records and create or update followups, tasks, solutions, and timeline document links with the asynchronous glpi_python_client.GlpiClient. Use when handling ticket notes, followups, tasks, solutions, or attached documents on a GLPI ticket timeline." +description: "Read GLPI ticket timeline records and create or update followups, tasks, solutions, and timeline document links with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient. Use when handling ticket notes, followups, tasks, solutions, or attached documents on a GLPI ticket timeline." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.3.0" + version: "0.4.0" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index d10b499..5256019 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -1,17 +1,17 @@ ---- +--- name: glpi-ticket-workflow -description: "Search, fetch, create, update, and delete GLPI tickets with the asynchronous glpi_python_client.GlpiClient and the GetTicket/PostTicket/PatchTicket/DeleteTicket models. Use for GLPI ticket records, ticket filters, fields, pagination, status, priority, category, location, or instance-specific extra_payload values." +description: "Search, fetch, create, update, and delete GLPI tickets with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetTicket/PostTicket/PatchTicket/DeleteTicket models. Use for GLPI ticket records, ticket filters, fields, pagination, status, priority, category, location, or instance-specific extra_payload values." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.3.0" + version: "0.4.0" --- # GLPI Ticket Workflow > The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. -Use this skill for ticket reads and writes through the public asynchronous client. Tickets live under `/Assistance/Ticket` on the GLPI v2 API and are exposed by five `GlpiClient` methods: `search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, and `delete_ticket`. +Use this skill for ticket reads and writes through the public client. Tickets live under `/Assistance/Ticket` on the GLPI v2 API and are exposed by five methods, present on both `GlpiClient` and `AsyncGlpiClient` with identical signatures: `search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, and `delete_ticket`. ## Procedure diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 43525ab..6a3cbb1 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -1,11 +1,11 @@ ---- +--- name: glpi-user-location-provisioning -description: "Search GLPI users, locations, and entities, or create, update, and delete users and locations and entities with the asynchronous glpi_python_client.GlpiClient and the matching Get/Post/Patch/Delete models. Use for user lookup, entity lookup, location lookup, user provisioning, location creation, GLPI entity defaults, or RSQL filters." +description: "Search GLPI users, locations, and entities, or create, update, and delete users and locations and entities with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the matching Get/Post/Patch/Delete models. Use for user lookup, entity lookup, location lookup, user provisioning, location creation, GLPI entity defaults, or RSQL filters." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities." metadata: package: glpi-python-client - version: "0.3.0" + version: "0.4.0" --- # GLPI User, Location, And Entity Provisioning