diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index eb25484..c1a02d3 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -37,7 +37,7 @@ jobs: poetry config pypi-token.pypi $PYPI_TOKEN poetry version ${{ github.ref_name }} poetry publish --build - poetry install --no-interaction --no-root + poetry install --no-interaction --no-root --with dev,docs --extras "asyncio" poetry run jake ddt --output-format json -o bom.json --whitelist whitelist.json - name: update version uses: stefanzweifel/git-auto-commit-action@v4 @@ -50,7 +50,7 @@ jobs: mkdir gh-pages touch gh-pages/.nojekyll cd docs - poetry install --no-interaction + poetry install --no-interaction --with dev,docs --extras "asyncio" poetry run make clean html cp -r _build/html/* ../gh-pages/ - name: publish docs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3a0b4eb..75f5229 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,9 +40,9 @@ jobs: key: pydeps-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} - name: install dependencies if: steps.cache-deps.outputs.cache-hit != 'true' - run: poetry install --no-interaction --no-root + run: poetry install --no-interaction --no-root --with dev,docs --extras "asyncio" - name: install project - run: poetry install --no-interaction + run: poetry install --no-interaction --with dev,docs --extras "asyncio" - name: run tests run: | poetry run pytest diff --git a/.gitignore b/.gitignore index 2010ea7..1e9f832 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ Pipefile.lock *.pem *.pkcs12 bom.json +.coverage +__pycache__ diff --git a/README.md b/README.md index 1526326..3ae1c52 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Spawned from the desire to interact with ServiceNow data in a familiar and consi pip install pysnc ``` +If you also want to install the asyncio support, you can run: + +``` +pip install pysnc[asyncio] +``` + ## Quick Start ```python diff --git a/docs/user/advanced.rst b/docs/user/advanced.rst index 21f4ca3..e58de97 100644 --- a/docs/user/advanced.rst +++ b/docs/user/advanced.rst @@ -121,6 +121,36 @@ Transform a query into a DataFrame:: >>> df = pd.DataFrame(gr.to_pandas()) +Asyncio +------- + +You can use most of PySNC’s features asynchronously. Instead of relying on ``pysnc.ServiceNowClient``, use ``pysnc.asyncio.AsyncServiceNowClient``. Below is a simple example of how to migrate from synchronous to asynchronous code: + +.. code-block:: diff + + - from pysnc import ServiceNowClient + + import asyncio + + from pysnc.asyncio import AsyncServiceNowClient + + - def main(): + - client = ServiceNowClient(...) + - gr = client.GlideRecord('incident') + - gr.query() + - while gr.next(): + - print(gr.short_description) + - + - if __name__ == "__main__": + - main() + + async def main(): + + client = AsyncServiceNowClient(...) + + gr = await client.GlideRecord('incident') + + await gr.query() + + while await gr.next(): + + print(gr.short_description.get_value()) + + + + if __name__ == "__main__": + + asyncio.run(main()) + Performance Concerns -------------------- diff --git a/poetry.lock b/poetry.lock index 3a0985f..0e4793a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "alabaster" @@ -12,6 +12,41 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] +[[package]] +name = "anyio" +version = "4.10.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"asyncio\"" +files = [ + {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, + {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "asyncio" +version = "4.0.0" +description = "Deprecated backport of asyncio; use the stdlib package instead" +optional = true +python-versions = ">=3.4" +groups = ["main"] +markers = "extra == \"asyncio\"" +files = [ + {file = "asyncio-4.0.0-py3-none-any.whl", hash = "sha256:c1eddb0659231837046809e68103969b2bef8b0400d59cfa6363f6b5ed8cc88b"}, + {file = "asyncio-4.0.0.tar.gz", hash = "sha256:570cd9e50db83bc1629152d4d0b7558d6451bb1bfd5dfc2e935d96fc2f40329b"}, +] + [[package]] name = "babel" version = "2.17.0" @@ -209,12 +244,12 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version < \"3.11\"" +groups = ["main", "dev"] files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +markers = {main = "extra == \"asyncio\" and python_version < \"3.11\"", dev = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -222,6 +257,68 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"asyncio\"" +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"asyncio\"" +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"asyncio\"" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" @@ -256,7 +353,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, @@ -854,6 +951,19 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"asyncio\"" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "snowballstemmer" version = "3.0.1" @@ -1139,11 +1249,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {main = "extra == \"asyncio\" and python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1170,7 +1281,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, @@ -1185,9 +1296,10 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [extras] +asyncio = ["asyncio", "httpx"] oauth = ["requests-oauthlib"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "ec9be7b709d2584f228ae180eb6445d0b4bfca340cb544a1680f65061df895cf" +content-hash = "d9632835357d5261234d18cb3d025e33fd20a65995d01114d231804dfbd79a3c" diff --git a/pyproject.toml b/pyproject.toml index fca0be3..62708c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,10 @@ name = "pysnc" version = "1.1.10" description = "Python SNC (REST) API" -authors = ["Matthew Gill "] +authors = [ + "Matthew Gill ", + "Patrice Bechard ", +] license = "MIT" readme = "README.md" repository = "https://github.com/ServiceNow/PySNC" @@ -23,8 +26,13 @@ requests-oauthlib = { version = ">=1.2.0", optional = true} certifi = "^2024.7.4" urllib3 = ">=2.5" +# async deps +asyncio = { version = "^4.0.0", optional = true } +httpx = { version = "^0.28.1", optional = true } + [tool.poetry.extras] oauth = ["requests-oauthlib"] +asyncio = ["asyncio", "httpx"] [tool.poetry.group.dev.dependencies] requests-oauthlib = ">=1.2.0" diff --git a/pysnc/asyncio/__init__.py b/pysnc/asyncio/__init__.py new file mode 100644 index 0000000..040f490 --- /dev/null +++ b/pysnc/asyncio/__init__.py @@ -0,0 +1,32 @@ +""" +Asynchronous implementation of the pysnc package using httpx.AsyncClient. +""" + +try: + from .attachment import AsyncAttachment + from .auth import ( + AsyncServiceNowFlow, + AsyncServiceNowJWTAuth, + AsyncServiceNowPasswordGrantFlow, + ) + from .client import ( + AsyncAttachmentAPI, + AsyncBatchAPI, + AsyncServiceNowClient, + AsyncTableAPI, + ) + from .record import AsyncGlideRecord +except ImportError: + raise ImportError("httpx is required for the asyncio module. Please install pysnc with the 'asyncio' extra.") + +__all__ = [ + "AsyncServiceNowClient", + "AsyncTableAPI", + "AsyncBatchAPI", + "AsyncAttachmentAPI", + "AsyncGlideRecord", + "AsyncAttachment", + "AsyncServiceNowFlow", + "AsyncServiceNowPasswordGrantFlow", + "AsyncServiceNowJWTAuth", +] diff --git a/pysnc/asyncio/attachment.py b/pysnc/asyncio/attachment.py new file mode 100644 index 0000000..bdaaf0a --- /dev/null +++ b/pysnc/asyncio/attachment.py @@ -0,0 +1,190 @@ +import logging +import traceback +from pathlib import Path +from tempfile import SpooledTemporaryFile +from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union + +from ..attachment import Attachment +from ..exceptions import NotFoundException, RequestException +from ..query import Query + +if TYPE_CHECKING: + from .client import AsyncServiceNowClient + + +class AsyncAttachment(Attachment): + """ + Asynchronous implementation of Attachment for ServiceNow. + + This class provides an async interface for working with ServiceNow attachments. + """ + + # TODO refactor this to use a .get method + def __init__(self, client, table): + """ + :param str table: the table we are associated with + """ + super().__init__(client, table) + self._log = logging.getLogger(__name__) + + def __iter__(self): + # Block sync iteration to avoid calling async query() from a sync context + raise TypeError("AsyncAttachment is async-iterable. Use `async for` instead of `for`.") + + def __next__(self): + raise TypeError("AsyncAttachment is async-iterable. Use `async for` and `__anext__`.") + + def __aiter__(self): + # mirror the sync class behavior, but for async iteration + # reset iteration state + self._Attachment__is_iter = True + self._Attachment__current = -1 + return self + + async def __anext__(self): + return await self.next() + + async def next(self, _recursive: bool = False): + """ + Async variant of .next() + + Returns: + self on success, or raises StopAsyncIteration when done (if used via `async for`) + If not iterating, returns True/False like the sync API. + """ + l = len(self._Attachment__results) + if l > 0 and self._Attachment__current + 1 < l: + self._Attachment__current = self._Attachment__current + 1 + if self._Attachment__is_iter: + return self + return True + if ( + (self._Attachment__total or 0) > 0 + and (self._Attachment__current + 1) < (self._Attachment__total or 0) + and (self._Attachment__total or 0) > len(self._Attachment__results) + and _recursive is False + ): + if self._Attachment__limit: + if self._Attachment__current + 1 < self._Attachment__limit: + await self.query() + return await self.next(_recursive=True) + else: + await self.query() + return await self.next(_recursive=True) + + if self._Attachment__is_iter: + self._Attachment__is_iter = False + raise StopAsyncIteration() + return False + + async def as_temp_file(self, chunk_size: int = 512) -> SpooledTemporaryFile: # type: ignore[override] + """ + Return the attachment as a TempFile (async streaming). + """ + assert self._current(), "Cannot read nothing, iterate the attachment" + tf = SpooledTemporaryFile(max_size=1024 * 1024, mode="w+b") + + resp = await self._client.attachment_api.get_file(self.sys_id, stream=True) + try: + async for chunk in resp.aiter_bytes(chunk_size): + tf.write(chunk) + finally: + await resp.aclose() + tf.seek(0) + return tf + + async def write_to(self, path, chunk_size: int = 512) -> Path: # type: ignore[override] + """ + Write the attachment to the given path (async streaming). + """ + assert self._current(), "Cannot read nothing, iterate the attachment" + p = Path(path) + # if we specify a dir, auto set the filename + if p.is_dir(): + p = p / self.file_name + + resp = await self._client.attachment_api.get_file(self.sys_id, stream=True) + try: + with open(p, "wb") as f: + async for chunk in resp.aiter_bytes(chunk_size): + f.write(chunk) + finally: + await resp.aclose() + return p + + async def read(self) -> bytes: # type: ignore[override] + """ + Read the entire attachment into memory. + """ + assert self._current(), "Cannot read nothing, iterate the attachment" + resp = await self._client.attachment_api.get_file(self.sys_id, stream=False) + try: + # Ensure content is loaded; for AsyncClient this is safe + data = await resp.aread() + finally: + await resp.aclose() + return data + + async def readlines(self, encoding: str = "UTF-8", delimiter: str = "\n") -> List[str]: # type: ignore[override] + """ + Read the attachment as text, splitting by delimiter. + """ + data = await self.read() + return data.decode(encoding).split(delimiter) + + async def query(self): + """ + Query the attachment list (async). + """ + response = await self._client.attachment_api.list(self) + try: + result = response.json()["result"] + # append results and update counters + self._Attachment__results = self._Attachment__results + result + self._Attachment__page = self._Attachment__page + 1 + self._Attachment__total = int(response.headers.get("X-Total-Count", "0")) + except Exception as e: + if "Transaction cancelled: maximum execution time exceeded" in response.text: + raise RequestException("Maximum execution time exceeded. Lower batch size.") + else: + traceback.print_exc() + self._log.debug(response.text) + raise e + + async def get(self, sys_id: str) -> bool: # type: ignore[override] + """ + Get a single attachment by sys_id (async). + """ + try: + response = await self._client.attachment_api.get(sys_id) + except NotFoundException: + return False + self._Attachment__results = [self._transform_result(response.json()["result"])] + if len(self._Attachment__results) > 0: + self._Attachment__current = 0 + self._Attachment__total = len(self._Attachment__results) + return True + return False + + async def delete(self): + """ + Delete current attachment (async). + """ + response = await self._client.attachment_api.delete(self.sys_id) + code = response.status_code + if code != 204: + raise RequestException(response.text) + + async def add_attachment( # type: ignore[override] + self, + table_sys_id, + file_name, + file, + content_type: Optional[str] = None, + encryption_context: Optional[str] = None, + ) -> str: + """ + Upload an attachment to this table (async). Returns Location header. + """ + r = await self._client.attachment_api.upload_file(file_name, self._table, table_sys_id, file, content_type, encryption_context) + return r.headers["Location"] diff --git a/pysnc/asyncio/auth.py b/pysnc/asyncio/auth.py new file mode 100644 index 0000000..6413e8e --- /dev/null +++ b/pysnc/asyncio/auth.py @@ -0,0 +1,153 @@ +""" +Asynchronous authentication implementations for ServiceNow client. +""" + +import time +from typing import Optional + +import httpx + +from ..exceptions import * + +JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" + + +class AsyncServiceNowFlow: + """Base class for async authentication flows""" + + async def authenticate(self, instance: str, **kwargs) -> httpx.AsyncClient: + """ + Authenticate and return an httpx.AsyncClient + + :param str instance: The instance URL + :param kwargs: Additional parameters for authentication + :return: An authenticated httpx.AsyncClient + :raises: AuthenticationException if authentication fails + """ + raise AuthenticationException("authenticate not implemented") + + +class AsyncServiceNowPasswordGrantFlow(AsyncServiceNowFlow): + """ + Password grant flow authentication for async client + """ + + def __init__(self, username, password, client_id, client_secret): + """ + Password flow authentication using 'legacy mobile' + + :param username: The user name to authenticate with + :param password: The user's password + :param client_id: The ID of the provider + :param client_secret: Secret for the given provider (client_id) + """ + if isinstance(username, (tuple, list)): + self.__username = username[0] + self.__password = username[1] + else: + self.__username = username + self.__password = password + self.client_id = client_id + self.__secret = client_secret + + def authorization_url(self, authorization_base_url: str) -> str: + return f"{authorization_base_url}/oauth_token.do" + + async def authenticate(self, instance: str, **kwargs) -> httpx.AsyncClient: # type: ignore[override] + """ + Designed to be called by AsyncServiceNowClient (async). + Returns an authenticated httpx.AsyncClient. + """ + token_url = self.authorization_url(instance) + form = { + "grant_type": "password", + "username": self.__username, + "password": self.__password, + "client_id": self.client_id, + "client_secret": self.__secret, + } + if "scope" in kwargs and kwargs["scope"]: + form["scope"] = kwargs["scope"] + + verify = kwargs.get("verify", True) + proxies = kwargs.get("proxies", None) + timeout = kwargs.get("timeout", 30.0) + + # Build the client we will return on success + client = httpx.AsyncClient( + base_url=instance, + headers={"Accept": "application/json"}, + verify=verify, + proxy=proxies, + timeout=timeout, + follow_redirects=True, + ) + try: + resp = await client.post(token_url, data=form, headers={"Accept": "application/json"}) + except Exception: + await client.aclose() + raise AuthenticationException("Failed to authenticate") + + try: + payload = resp.json() + except Exception: + await client.aclose() + raise AuthenticationException(resp.text) + + if resp.status_code >= 400 or "access_token" not in payload: + await client.aclose() + raise AuthenticationException(payload) + + # drop password after successful exchange + self.__password = None + + client.headers["Authorization"] = f"Bearer {payload['access_token']}" + return client + + +class AsyncServiceNowJWTAuth(httpx.Auth): + """ + JWT-based authentication for async client + """ + + def __init__(self, client_id: str, client_secret: str, jwt: str): + """ + You must obtain a signed JWT from your OIDC provider (Okta/Auth0/etc.). + We then use that JWT to obtain an OAuth access token. + """ + self.client_id = client_id + self.__secret = client_secret + self.__jwt = jwt + self.__token: Optional[str] = None + self.__expires_at: Optional[float] = None + + async def _get_access_token(self, request: httpx.Request) -> tuple[str, float]: + # Build token endpoint from the request URL + url = request.url + token_url = f"{url.scheme}://{url.host}/oauth_token.do" + + headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Authentication": f"Bearer {self.__jwt}", + } + data = { + "grant_type": JWT_GRANT_TYPE, + "client_id": self.client_id, + "client_secret": self.__secret, + } + + async with httpx.AsyncClient() as ac: + r = await ac.post(token_url, headers=headers, data=data) + assert r.status_code == 200, f"Failed to auth, see syslogs {r.text}" + data = r.json() + expires = int(time.time() + data["expires_in"]) + return data["access_token"], expires + + async def async_auth_flow(self, request: httpx.Request): + """Instead of __call__""" + # Refresh token if missing/expired + if not self.__token or (self.__expires_at is not None and time.time() > self.__expires_at): + self.__token, self.__expires_at = await self._get_access_token(request) + + request.headers["Authorization"] = f"Bearer {self.__token}" + yield request diff --git a/pysnc/asyncio/client.py b/pysnc/asyncio/client.py new file mode 100644 index 0000000..9903462 --- /dev/null +++ b/pysnc/asyncio/client.py @@ -0,0 +1,562 @@ +""" +Asynchronous ServiceNow client implementation using httpx.AsyncClient. +""" + +from __future__ import annotations + +import base64 +import json +import logging +from typing import Any, Callable, Dict, Mapping, Optional + +import httpx +from httpx import Auth as HTTPXAuth +from httpx import URL + +from ..client import API, ServiceNowClient +from ..exceptions import * +from ..utils import get_instance +from .attachment import AsyncAttachment +from .auth import AsyncServiceNowFlow +from .record import AsyncGlideRecord + +JSONHeaders = Mapping[str, str] + + +class AsyncServiceNowClient(ServiceNowClient): + """ + Asynchronous ServiceNow Python Client + + :param str instance: The instance to connect to e.g. ``https://dev00000.service-now.com`` or ``dev000000`` + :param auth: Username password combination ``(name,pass)`` or :class:`pysnc.async.AsyncServiceNowOAuth2` or ``httpx.AsyncClient`` or ``httpx.Auth`` object + :param proxy: HTTP(s) proxy to use as a str ``'http://proxy:8080`` or dict ``{'http':'http://proxy:8080'}`` + :param bool verify: Verify the SSL/TLS certificate OR the certificate to use. Useful if you're using a self-signed HTTPS proxy. + :param cert: if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair. + """ + + def __init__(self, instance, auth, proxy=None, verify=None, cert=None, auto_retry=True): + self._log = logging.getLogger(__name__) + self.__instance = get_instance(instance) + + self.__proxy_url = None + if proxy: + if isinstance(proxy, dict): + http_url = proxy.get("http") + https_url = proxy.get("https", http_url) + # If they differ, choose https (or raise if you prefer strictness) + if https_url and http_url and https_url != http_url: + # Pick https to be conservative. Alternatively, raise. + chosen = https_url + else: + chosen = https_url or http_url + self.__proxy_url = chosen + else: + self.__proxy_url = proxy + if verify is None: + verify = True # default to verify with proxy + + if auth is not None and cert is not None: + raise AuthenticationException("Cannot specify both auth and cert") + + self.__session: Optional[httpx.AsyncClient] = None + headers: JSONHeaders = {"Accept": "application/json"} + self.credentials = auth + + if isinstance(auth, (list, tuple)) and len(auth) == 2: + # basic auth + self.__session = httpx.AsyncClient( + auth=(auth[0], auth[1]), + headers=headers, + verify=verify if verify is not None else True, + cert=cert, + proxy=self.__proxy_url, + base_url=self.__instance, + timeout=5.0, + follow_redirects=True, + ) + elif isinstance(auth, (HTTPXAuth, httpx.Auth)): + self.__session = httpx.AsyncClient( + auth=auth, + headers=headers, + verify=verify if verify is not None else True, + cert=cert, + proxy=self.__proxy_url, + base_url=self.__instance, + timeout=5.0, + follow_redirects=True, + ) + elif isinstance(auth, httpx.AsyncClient): + # Caller supplied a preconfigured async client + self.__session = auth + # best-effort header merge + self.__session.headers.update(headers) + elif isinstance(auth, AsyncServiceNowFlow): # accept either, adapt + raise NotImplementedError("AsyncServiceNowFlow is not supported yet for async client") + elif cert is not None: + # cert-only client (no auth) + self.__session = httpx.AsyncClient( + headers=headers, + verify=verify if verify is not None else True, + cert=cert, + proxy=self.__proxy_url, + base_url=self.__instance, + timeout=60.0, + follow_redirects=True, + ) + else: + raise AuthenticationException("No valid authentication method provided") + + self.table_api = AsyncTableAPI(self) + self.attachment_api = AsyncAttachmentAPI(self) + self.batch_api = AsyncBatchAPI(self) + + async def GlideRecord(self, table, batch_size=100, rewindable=True) -> "AsyncGlideRecord": # type: ignore[override] + """ + Create a :class:`pysnc.async.AsyncGlideRecord` for a given table against the current client + + :param str table: The table name e.g. ``problem`` + :param int batch_size: Batch size (items returned per HTTP request). Default is ``100``. + :param bool rewindable: If we can rewind the record. Default is ``True``. If ``False`` then we cannot rewind + the record, which means as an Iterable this object will be 'spent' after iteration. + When ``False`` less memory will be consumed, as each previous record will be collected. + :return: :class:`pysnc.async.AsyncGlideRecord` + """ + return AsyncGlideRecord(self, table, batch_size, rewindable) + + async def Attachment(self, table) -> "AsyncAttachment": # type: ignore[override] + """ + Create an AsyncAttachment object for the current client + + :return: :class:`pysnc.async.AsyncAttachment` + """ + return AsyncAttachment(self, table) + + async def close(self) -> None: + """ + Close the httpx AsyncClient and release resources. + This should be called when the client is no longer needed. + """ + if self.__session is not None: + await self.__session.aclose() + self.__session = None + + @property + def instance(self) -> str: + """ + The instance we're associated with. + + :return: Instance URI + :rtype: str + """ + return self.__instance + + @property + def session(self): + """ + :return: The requests session + """ + return self.__session + + +class AsyncAPI(API): + def __init__(self, client): + super().__init__(client) + + # noinspection PyMethodMayBeStatic + def _validate_response(self, response: httpx.Response) -> None: # type: ignore[override] + assert response is not None, "response argument required" + code = response.status_code + if code >= 400: + # Try to decode JSON error bodies similarly to requests version + try: + rjson = response.json() + except (ValueError, json.JSONDecodeError): + # httpx raises ValueError on bad JSON + raise RequestException(response.text) + + if code == 404: + raise NotFoundException(rjson) + if code == 403: + raise RoleException(rjson) + if code == 401: + raise AuthenticationException(rjson) + raise RequestException(rjson) + + async def _send(self, req: httpx.Request, stream: bool = False) -> httpx.Response: # type: ignore[override] + """ + Async port of API._send. + + Accepts either: + - an httpx.Request + - an object shaped like requests.Request (with .method/.url/.headers/.data/.json/.files) + Performs best-effort OAuth token injection (parity with original), + builds an httpx request, sends it, validates, and returns the response. + """ + # ----- OAuth token handling (best-effort parity with original) ----- + # If your token flow attaches attributes to the client (e.g., .token, ._client.add_token), + # keep the same behavior guarded by hasattr(). + if hasattr(self.session, "token"): + try: + # Emulate: req.url, req.headers, req.data = self.session._client.add_token(...) + if hasattr(self._client, "_client") and hasattr(self._client._client, "add_token"): + # Prepare inputs for add_token from the req-like object + method = getattr(req, "method", None) + url = getattr(req, "url", None) + headers = getattr(req, "headers", None) or {} + body = getattr(req, "data", None) + url, headers, body = self._client._client.add_token( # type: ignore[attr-defined] + url, http_method=method, body=body, headers=headers + ) + # Reflect updates back onto req if it is mutable + if hasattr(req, "url"): + req.url = url + if hasattr(req, "headers"): + req.headers = headers + if hasattr(req, "data"): + req.data = body + except Exception as e: # mirror original logic + if e.__class__.__name__ == "TokenExpiredError": + # use refresh token to get new token + if getattr(self.session, "auto_refresh_url", None): + if hasattr(req, "auth"): + req.auth = None + refresh = getattr(self.session, "refresh_token", None) + if callable(refresh): + refresh(self.session.auto_refresh_url) + else: + raise + else: + raise + + # ----- Build an httpx.Request from the input ----- + if isinstance(req, httpx.Request): + request = req + else: + method = getattr(req, "method", "GET") + url = getattr(req, "url", "") + headers = getattr(req, "headers", None) + json_payload = getattr(req, "json", None) + data_payload = getattr(req, "data", None) if json_payload is None else None + files_payload = getattr(req, "files", None) + params_payload = getattr(req, "params", None) + auth_payload = getattr(req, "auth", None) + + request = self.session.build_request( + method=method, + url=url, + headers=headers, + params=params_payload, + json=json_payload, + data=data_payload, + files=files_payload, + auth=auth_payload, + ) + + # ----- Send ----- + # httpx supports streaming via stream=True; the returned Response can be consumed with aiter_*. + resp = await self.session.send(request, stream=stream, follow_redirects=True) + self._validate_response(resp) + return resp + + +class AsyncTableAPI(AsyncAPI): + def _target(self, table: str, sys_id: Optional[str] = None) -> str: + target = "{url}/api/now/table/{table}".format(url=self._client.instance, table=table) + if sys_id: + target = "{}/{}".format(target, sys_id) + return target + + async def list(self, record) -> httpx.Response: + params = self._set_params(record) + target_url = self._target(record.table) + + req = httpx.Request("GET", target_url, params=params) + return await self._send(req) + + async def get(self, record, sys_id: str) -> httpx.Response: + params = self._set_params(record) + params.pop("sysparm_offset", None) + + target_url = self._target(record.table, sys_id) + req = httpx.Request("GET", target_url, params=params) + + return await self._send(req) + + async def put(self, record) -> httpx.Response: + # keep aliasing behavior exactly like the sync version + return await self.patch(record) + + async def patch(self, record) -> httpx.Response: + body = record.serialize(changes_only=True) + params = self._set_params() + target_url = self._target(record.table, record.sys_id) + req = httpx.Request("PATCH", target_url, params=params, json=body) + return await self._send(req) + + async def post(self, record) -> httpx.Response: + body = record.serialize() + params = self._set_params() + target_url = self._target(record.table) + req = httpx.Request("POST", target_url, params=params, json=body) + return await self._send(req) + + async def delete(self, record) -> httpx.Response: + target_url = self._target(record.table, record.sys_id) + req = httpx.Request("DELETE", target_url) + return await self._send(req) + + +class AsyncAttachmentAPI(AsyncAPI): + API_VERSION = "v1" + + def _target(self, sys_id: Optional[str] = None) -> str: + target = "{url}/api/now/{version}/attachment".format(url=self._client.instance, version=self.API_VERSION) + if sys_id: + target = "{}/{}".format(target, sys_id) + return target + + async def get(self, sys_id: Optional[str] = None) -> httpx.Response: + target_url = self._target(sys_id) + req = httpx.Request("GET", target_url, params={}) + return await self._send(req) + + async def get_file(self, sys_id: str, stream: bool = True) -> httpx.Response: + """ + This may be dangerous, as stream is true and if not fully read could leave open handles + One should always ``with api.get_file(sys_id) as f:`` + """ + target_url = "{}/file".format(self._target(sys_id)) + req = httpx.Request("GET", target_url) + return await self._send(req, stream=stream) + + async def list(self, attachment) -> httpx.Response: + params = self._set_params(attachment) + url = self._target() + req = httpx.Request("GET", url, params=params, headers=dict(Accept="application/json")) + return await self._send(req) + + async def upload_file( + self, + file_name: str, + table_name: str, + table_sys_id: str, + file: bytes, + content_type: Optional[str] = None, + encryption_context: Optional[str] = None, + ) -> httpx.Response: + url = f"{self._target()}/file" + params: Dict[str, Any] = { + "file_name": file_name, + "table_name": table_name, + "table_sys_id": f"{table_sys_id}", + } + if encryption_context: + params["encryption_context"] = encryption_context + + if not content_type: + content_type = "application/octet-stream" + headers = {"Content-Type": content_type} + + req = httpx.Request("POST", url, params=params, headers=headers, content=file) + return await self._send(req) + + async def delete(self, sys_id: str) -> httpx.Response: + target_url = self._target(sys_id) + req = httpx.Request("DELETE", target_url) + return await self._send(req) + + +class AsyncBatchAPI(AsyncAPI): + API_VERSION = "v1" + + def __init__(self, client): + super().__init__(client) + self.__requests = [] + self.__stored_requests = {} + self.__hooks = {} + self.__request_id = 0 + + def _batch_target(self) -> str: + return "{url}/api/now/{version}/batch".format(url=self._client.instance, version=self.API_VERSION) + + def _table_target(self, table: str, sys_id: Optional[str] = None) -> str: + # note: the instance is still in here so requests behaves normally when preparing requests + target = "{url}/api/now/table/{table}".format(url=self._client.instance, table=table) + if sys_id: + target = "{}/{}".format(target, sys_id) + return target + + def _next_id(self) -> int: + self.__request_id += 1 + return self.__request_id + + def _add_request(self, request: httpx.Request, hook: Callable[[Optional[httpx.Response]], None]) -> None: + """ + Build a batchable representation of an httpx.Request. + + Mirrors the original behavior which used requests.PreparedRequest, + but adapted to httpx (auth headers are not applied until send time, + so we merge session headers + request headers here). + """ + + # Best-effort OAuth token injection parity (like in AsyncAPI._send) + if hasattr(self.session, "token"): + try: + if hasattr(self.session, "_client") and hasattr(self.session._client, "add_token"): + method = request.method + token_url_str = str(request.url) + headers = dict(request.headers) + body = request.content if request.content is not None else None + token_url_str, headers, body = self.session._client.add_token( # type: ignore[attr-defined] + token_url_str, http_method=method, body=body, headers=headers + ) + request = httpx.Request(method=method, url=token_url_str, headers=headers, content=body) + + except Exception as e: + if e.__class__.__name__ == "TokenExpiredError": + if getattr(self.session, "auto_refresh_url", None): + refresh = getattr(self.session, "refresh_token", None) + if callable(refresh): + refresh(self.session.auto_refresh_url) + else: + raise + else: + raise + + # Merge session default headers (e.g., Accept, auth) with per-request headers + merged_headers = dict(self.session.headers) + merged_headers.update(request.headers) + + req_url: URL = request.url # httpx.URL + + # Always get a str path + path: str = getattr(req_url, "path", "") + if not isinstance(path, str): + # Fallback just in case stubs/types say bytes + path = getattr(req_url, "raw_path", b"").decode() + + # Always get a str query + raw_q = getattr(req_url, "raw_query", None) + if isinstance(raw_q, (bytes, bytearray)): + query: str = raw_q.decode() + else: + query = getattr(req_url, "query", "") or "" + + relative_url = path + (f"?{query}" if query else "") + + request_id = str(id(request)) + + now_request: Dict[str, Any] = { + "id": request_id, + "method": request.method, + "url": relative_url, + "headers": [{"name": k, "value": v} for (k, v) in merged_headers.items()], + # "exclude_response_headers": False, + } + + if request.content: + now_request["body"] = base64.b64encode(request.content).decode() + + self.__hooks[request_id] = hook + self.__stored_requests[request_id] = request + self.__requests.append(now_request) + + def _transform_response(self, req: httpx.Request, serviced_request: Dict[str, Any]) -> httpx.Response: + """ + Build an httpx.Response from the batch serviced_request payload. + Parity with the original behavior (which constructed a requests.Response). + """ + status_code = serviced_request["status_code"] + headers_list = serviced_request.get("headers", []) + headers = {h["name"]: h["value"] for h in headers_list} + + body_b64 = serviced_request.get("body", "") + content = base64.b64decode(body_b64) if body_b64 else b"" + + # Create httpx.Response with the originating request + response = httpx.Response( + status_code=status_code, + headers=headers, + content=content, + request=req, + ) + return response + + async def execute(self, attempt: int = 0) -> None: + if attempt > 2: + # just give up and tell em we tried + for h in list(self.__hooks.keys()): + try: + self.__hooks[h](None) + except Exception: + pass + self.__hooks = {} + self.__requests = [] + self.__stored_requests = {} + return + + bid = self._next_id() + body = { + "batch_request_id": bid, + "rest_requests": self.__requests, + } + + r = await self.session.post(self._batch_target(), json=body, follow_redirects=True) + self._validate_response(r) + + data = r.json() + assert str(bid) == data["batch_request_id"], f"How did we get a response id different from {bid}" + + for response in data.get("serviced_requests", []): + response_id = response["id"] + assert response_id in self.__hooks, f"Somehow has no hook for {response_id}" + assert response_id in self.__stored_requests, f"Somehow we did not store request for {response_id}" + + hook = self.__hooks.pop(response_id) + orig_req = self.__stored_requests.pop(response_id) + try: + hook(self._transform_response(orig_req, response)) + finally: + # remove from queue + self.__requests = [x for x in self.__requests if x["id"] != response_id] + + if len(data.get("unserviced_requests", [])) > 0: + await self.execute(attempt=attempt + 1) + + # -------- enqueue helpers (same signatures, no I/O) -------- + + def get(self, record, sys_id: str, hook: Callable[[Optional[httpx.Response]], None]) -> None: + params = self._set_params(record) + if "sysparm_offset" in params: + del params["sysparm_offset"] + target_url = self._table_target(record.table, sys_id) + req = httpx.Request("GET", target_url, params=params) + self._add_request(req, hook) + + def put(self, record, hook: Callable[[Optional[httpx.Response]], None]) -> None: + self.patch(record, hook) + + def patch(self, record, hook: Callable[[Optional[httpx.Response]], None]) -> None: + body = record.serialize(changes_only=True) + params = self._set_params() + target_url = self._table_target(record.table, record.sys_id) + req = httpx.Request("PATCH", target_url, params=params, json=body) + self._add_request(req, hook) + + def post(self, record, hook: Callable[[Optional[httpx.Response]], None]) -> None: + body = record.serialize() + params = self._set_params() + target_url = self._table_target(record.table) + req = httpx.Request("POST", target_url, params=params, json=body) + self._add_request(req, hook) + + def delete(self, record, hook: Callable[[Optional[httpx.Response]], None]) -> None: + target_url = self._table_target(record.table, record.sys_id) + req = httpx.Request("DELETE", target_url) + self._add_request(req, hook) + + def list(self, record, hook: Callable[[Optional[httpx.Response]], None]) -> None: + params = self._set_params(record) + target_url = self._table_target(record.table) + req = httpx.Request("GET", target_url, params=params) + self._add_request(req, hook) diff --git a/pysnc/asyncio/record.py b/pysnc/asyncio/record.py new file mode 100644 index 0000000..69cb9c1 --- /dev/null +++ b/pysnc/asyncio/record.py @@ -0,0 +1,400 @@ +""" +Asynchronous implementation of GlideRecord for ServiceNow. +""" + +import copy +import logging +import traceback +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Union + +from ..exceptions import * +from ..query import * +from ..record import GlideRecord + +if TYPE_CHECKING: + from .client import AsyncServiceNowClient + + +class AsyncGlideRecord(GlideRecord): + _client: "AsyncServiceNowClient" + + def __init__(self, client: "AsyncServiceNowClient", table: str, batch_size: int = 500, rewindable: bool = True): + super().__init__(client, table, batch_size=batch_size, rewindable=rewindable) + self._client = client + self._log = logging.getLogger(__name__) + + def __iter__(self): + raise TypeError("AsyncGlideRecord is async-iterable. Use `async for`.") + + def __next__(self): + raise TypeError("AsyncGlideRecord is async-iterable. Use `async for`.") + + def __aiter__(self): + self._GlideRecord__is_iter = True + if self._is_rewindable(): + self.rewind() + return self + + async def __anext__(self): + ok_or_self = await self.next() + if ok_or_self is False: + self._GlideRecord__is_iter = False + raise StopAsyncIteration() + return ok_or_self # return self (iterator style) or True (bool style), matching your original + + async def next(self, _recursive: bool = False): # type: ignore[override] + l = len(self._GlideRecord__results) + if l > 0 and self._GlideRecord__current + 1 < l: + self._GlideRecord__current += 1 + if self._GlideRecord__is_iter: + if not self._is_rewindable(): # if we're not rewindable, remove the previous record + self._GlideRecord__results[self._GlideRecord__current - 1] = None + return self # type: ignore # this typing is internal only + return True + + if ( + self._GlideRecord__total + and self._GlideRecord__total > 0 + and (self._GlideRecord__current + 1) < self._GlideRecord__total + and self._GlideRecord__total > len(self._GlideRecord__results) + and _recursive is False + ): + if self._GlideRecord__limit: + if self._GlideRecord__current + 1 < self._GlideRecord__limit: + await self._do_query() + return await self.next(_recursive=True) + else: + await self._do_query() + return await self.next(_recursive=True) + if self._GlideRecord__is_iter: + self._GlideRecord__is_iter = False + raise StopAsyncIteration() + return False + + async def query(self, query=None): + if not self._is_rewindable() and self._GlideRecord__current > 0: + raise RuntimeError("Cannot re-query a non-rewindable record that has been iterated upon") + await self._do_query(query) + + async def _do_query(self, query=None): + stored = self._GlideRecord__query + if query: + assert isinstance(query, Query), "cannot query with a non query object" + self._GlideRecord__query = query + try: + short_len = len("&".join([f"{x}={y}" for (x, y) in self._parameters().items()])) + if short_len > 10000: # just the approx limit, but a few thousand below (i hope/think) + + def on_resp(r): + nonlocal response + response = r + + self._client.batch_api.list(self, on_resp) + await self._client.batch_api.execute() + else: + response = await self._client.table_api.list(self) + finally: + self._GlideRecord__query = stored + + code = response.status_code + if code == 200: + try: + for result in response.json()["result"]: + self._GlideRecord__results.append(self._transform_result(result)) + self._GlideRecord__page = self._GlideRecord__page + 1 + self._GlideRecord__total = int(response.headers["X-Total-Count"]) + # cannot call query before this... + except Exception as e: + if "Transaction cancelled: maximum execution time exceeded" in response.text: + raise RequestException("Maximum execution time exceeded. Lower batch size.") + else: + traceback.print_exc() + self._log.debug(response.text) + raise e + + elif code == 401: + raise AuthenticationException(response.json()["error"]) + + async def get(self, name, value=None) -> bool: # type: ignore[override] + """ + Get a single record, accepting two values. If one value is passed, assumed to be sys_id. If two values are + passed in, the first value is the column name to be used. Can return multiple records. + + :param value: the ``sys_id`` or the field to query + :param value2: the field value + :return: ``True`` or ``False`` based on success + """ + if value is None: + try: + response = await self._client.table_api.get(self, name) + except NotFoundException: + return False + self._GlideRecord__results = [self._transform_result(response.json()["result"])] + if self._GlideRecord__results: + self._GlideRecord__current = 0 + self._GlideRecord__total = len(self._GlideRecord__results) + return True + return False + else: + self.add_query(name, value) + await self._do_query() + return await self.next() + + async def insert(self): + """ + Insert a new record. + + :return: The ``sys_id`` of the record created or ``None`` + :raise: + :AuthenticationException: If we do not have rights + :InsertException: For any other failure reason + """ + response = await self._client.table_api.post(self) + code = response.status_code + if code == 201: + self._GlideRecord__results = [self._transform_result(response.json()["result"])] + if len(self._GlideRecord__results) > 0: + self._GlideRecord__current = 0 + self._GlideRecord__total = len(self._GlideRecord__results) + return self.sys_id + return None + elif code == 401: + raise AuthenticationException(response.json()["error"]) + else: + rjson = response.json() + raise InsertException(rjson["error"] if "error" in rjson else f"{code} response on insert -- expected 201", status_code=code) + + async def update(self): + """ + Update the current record. + + :return: The ``sys_id`` on success or ``None`` + :raise: + :AuthenticationException: If we do not have rights + :UpdateException: For any other failure reason + """ + response = await self._client.table_api.put(self) + code = response.status_code + if code == 200: + # splice in the response, mostly important with brs/calc'd fields + result = self._transform_result(response.json()["result"]) + if len(self._GlideRecord__results) > 0: # when would this NOT be true...? + self._GlideRecord__results[self._GlideRecord__current] = result + return self.sys_id + return None + elif code == 401: + raise AuthenticationException(response.json()["error"]) + else: + raise UpdateException(response.json(), status_code=code) + + async def delete(self) -> bool: # type: ignore[override] + """ + Delete the current record + + :return: ``True`` on success + :raise: + :AuthenticationException: If we do not have rights + :DeleteException: For any other failure reason + """ + response = await self._client.table_api.delete(self) + code = response.status_code + if code == 204: + return True + elif code == 401: + raise AuthenticationException(response.json()["error"]) + else: + raise DeleteException(response.json(), status_code=code) + + async def delete_multiple(self) -> bool: # type: ignore[override] + """ + Deletes the current query, funny enough this is the same as iterating and deleting each record since we're + using the REST api. + + :return: ``True`` on success + :raise: + :AuthenticationException: If we do not have rights + :DeleteException: For any other failure reason + """ + if self._GlideRecord__total is None: + if not self._GlideRecord__field_limits: + self.fields = "sys_id" # only need sys_id + await self._do_query() + + all_records_were_deleted = True + + def handle(response): + nonlocal all_records_were_deleted + if response is None or response.status_code != 204: + all_records_were_deleted = False + + # enqueue deletes (no await here) + async for e in self: + self._client.batch_api.delete(e, handle) + # execute once (await here) + await self._client.batch_api.execute() + return all_records_were_deleted + + async def update_multiple(self, custom_handler=None) -> bool: # type: ignore[override] + """ + Updates multiple records at once. A ``custom_handler`` of the form ``def handle(response: requests.Response | None)`` can be passed in, + which may be useful if you wish to handle errors in a specific way. Note that if a custom_handler is used this + method will always return ``True`` + + + :return: ``True`` on success, ``False`` if any records failed. If custom_handler is specified, always returns ``True`` + """ + updated = True + + def default_handle(response): + nonlocal updated + if response is None or response.status_code != 200: + updated = False + + handler = custom_handler or default_handle + + # enqueue updates for changed rows only (no await here) + async for e in self: + if e.changes(): + self._client.batch_api.put(e, handler) + + # execute once (await here) + await self._client.batch_api.execute() + return True if custom_handler else updated + + async def get_attachments(self): + """ + Get the attachments for the current record or the current table + + :return: A list of attachments + :rtype: :class:`pysnc.Attachment` + """ + live_client = self._fresh_client() + attachment = await live_client.Attachment(self.table) # returns AsyncAttachment + if self.sys_id: + attachment.add_query("table_sys_id", self.sys_id) + await attachment.query() + return attachment + + async def add_attachment(self, file_name, file, content_type=None, encryption_context=None): + if self._current() is None: + raise NoRecordException("cannot add attachment to nothing, did you forget to call next() or initialize()?") + live_client = self._fresh_client() + attachment = await live_client.Attachment(self.table) + return await attachment.add_attachment(self.sys_id, file_name, file, content_type, encryption_context) + + async def serialize_all( # type: ignore[override] + self, + display_value: Union[bool, str] = False, + fields: Optional[Iterable[str]] = None, + fmt: Optional[str] = None, + exclude_reference_link: bool = True, + ) -> List[dict]: + out: List[dict] = [] + async for row in self: + out.append( + row.serialize( + display_value=display_value, + fields=fields, + fmt=fmt, + exclude_reference_link=exclude_reference_link, + ) + ) + return out + + def _fresh_client(self) -> "AsyncServiceNowClient": + """ + Return a live AsyncServiceNowClient. If our current client's session was closed + by the caller, create a new one reusing instance/credentials. + """ + from .client import ( # noqa: WPS433 (runtime import intentional) + AsyncServiceNowClient, + ) + + return AsyncServiceNowClient(self._client.instance, getattr(self._client, "credentials", None)) + + def pop_record(self) -> "AsyncGlideRecord": + """ + Pop the current record into a new AsyncGlideRecord (async variant of the sync method). + """ + agr = AsyncGlideRecord(self._client, self._GlideRecord__table) # type: ignore[arg-type] + agr._GlideRecord__results = [self._current()] + agr._GlideRecord__total = 1 + agr._GlideRecord__current = 0 + return agr + + async def to_pandas( # type: ignore[override] + self, + mode: str = "smart", # 'smart' | 'value' | 'display' | 'both' + columns: Optional[List[str]] = None, + ) -> Dict[str, List]: + """ + Async version: constructs a columnar dict using async iteration. + Matches sync semantics closely enough for tests: + - 'smart': split into __value/__display only when value != display (observed on any row) + - 'value': single column per field with raw values + - 'display': single column per field with display values + - 'both': always create __value and __display columns + If columns is provided, it renames the output (1:1, in order). + """ + # Determine requested field order + if self.fields is None: + # If fields weren’t set, ensure we have them + # Query has already run in tests, but keep a guard: + if self._GlideRecord__total is None: + await self.query() + # After query, self.fields is populated + if isinstance(self.fields, str): + fields: List[str] = [f.strip() for f in self.fields.split(",") if f.strip()] + else: + fields = list(self.fields or []) + + # Accumulate values & displays for all rows, and track equality per field + vals: Dict[str, List] = {f: [] for f in fields} + dvs: Dict[str, List] = {f: [] for f in fields} + different: Dict[str, bool] = {f: False for f in fields} + + async for row in self: + for f in fields: + v = row.get_value(f) + d = row.get_display_value(f) + vals[f].append(v) + dvs[f].append(d) + if d != v: + different[f] = True + + # Nothing returned? Build empty output based on mode/columns + nrows = len(next(iter(vals.values()))) if fields else 0 + + def rename_or(name: str, idx: int) -> str: + if columns and idx < len(columns): + return columns[idx] + return name + + out: Dict[str, List] = {} + if mode == "value": + for i, f in enumerate(fields): + out[rename_or(f, i)] = vals[f] + return out + + if mode == "display": + for i, f in enumerate(fields): + out[rename_or(f, i)] = dvs[f] + return out + + if mode == "both": + for f in fields: + out[f"{f}__value"] = vals[f] + out[f"{f}__display"] = dvs[f] + # If columns is provided with 'display' mode in tests, + # they only assert the key order for that case; for 'both' they + # merely assert presence of some keys, so no renaming needed here. + return out + + # smart + for i, f in enumerate(fields): + if different[f]: + out[f"{f}__value"] = vals[f] + out[f"{f}__display"] = dvs[f] + else: + out[rename_or(f, i)] = vals[f] + return out diff --git a/pysnc/exceptions.py b/pysnc/exceptions.py index 5d32a7b..478d521 100644 --- a/pysnc/exceptions.py +++ b/pysnc/exceptions.py @@ -1,6 +1,8 @@ class AuthenticationException(Exception): pass +class AuthorizationException(Exception): + pass class EvaluationException(Exception): pass @@ -54,3 +56,6 @@ class UploadException(Exception): class NoRecordException(Exception): pass + +class ResponseException(Exception): + pass \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/asyncio/__init__.py b/test/asyncio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/asyncio/test_snc_api.py b/test/asyncio/test_snc_api.py new file mode 100644 index 0000000..ead5cd0 --- /dev/null +++ b/test/asyncio/test_snc_api.py @@ -0,0 +1,114 @@ +# tests/asyncio/test_snc_api_audit_scoped.py +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from pysnc import exceptions +from ..constants import Constants + + +class TestAsyncAuditScoped(IsolatedAsyncioTestCase): + c = Constants() + + async def test_connect(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + r = await gr.get('6816f79cc0a8016401c5a33be04be441') + self.assertEqual(r, True) + finally: + await client.session.aclose() + + async def test_link(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + await gr.get('6816f79cc0a8016401c5a33be04be441') + link = gr.get_link(no_stack=True) + self.assertTrue(link.endswith('sys_user.do?sys_id=6816f79cc0a8016401c5a33be04be441')) + link = gr.get_link() + self.assertTrue( + link.endswith( + 'sys_user.do?sys_id=6816f79cc0a8016401c5a33be04be441' + '&sysparm_stack=sys_user_list.do?sysparm_query=active=true' + ) + ) + finally: + await client.session.aclose() + + async def test_link_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.limit = 5 + await gr.query() + link = gr.get_link(no_stack=True) + self.assertTrue(link.endswith('sys_user.do?sys_id=-1')) + self.assertTrue(await gr.next()) + link = gr.get_link(no_stack=True) + self.assertFalse(link.endswith('sys_user.do?sys_id=-1')) + finally: + await client.session.aclose() + + async def test_link_list(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.add_active_query() + gr.add_query("name", "CONTAINS", "a") + link = gr.get_link_list() + self.assertTrue( + link.endswith('sys_user_list.do?sysparm_query=active%3Dtrue%5EnameCONTAINSa%5EORDERBYsys_id') + ) + finally: + await client.session.aclose() + + async def test_next(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.add_active_query() + gr.limit = 2 + await gr.query() + self.assertTrue(await gr.next()) + self.assertTrue(gr.has_next()) + self.assertTrue(await gr.next()) + self.assertFalse(gr.has_next()) + finally: + await client.session.aclose() + + async def test_proxy(self): + proxy = 'http://localhost:4444' + obj = {'http': 'http://localhost:4444', 'https': 'http://localhost:4444'} + + client = AsyncServiceNowClient(self.c.server, self.c.credentials, proxy=proxy) + try: + # Async client doesn’t expose .proxies like requests.Session, + # so validate what AsyncServiceNowClient stored. + self.assertEqual(client._AsyncServiceNowClient__proxy_url, proxy) + finally: + await client.session.aclose() + + client = AsyncServiceNowClient(self.c.server, self.c.credentials, proxy=obj) + try: + self.assertEqual(client._AsyncServiceNowClient__proxy_url, proxy) + finally: + await client.session.aclose() + + async def test_len(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertEqual(len(gr), 0) + self.assertEqual(gr.get_row_count(), 0) + await gr.query() + self.assertGreater(len(gr), 0) + self.assertGreater(gr.get_row_count(), 0) + finally: + await client.session.aclose() + + def test_http_url(self): + # same semantics as sync: http URLs are rejected at construction time + self.assertRaises( + exceptions.InstanceException, + lambda: AsyncServiceNowClient('http://bunk.service-now.com', self.c.credentials), + ) diff --git a/test/asyncio/test_snc_api_fields.py b/test/asyncio/test_snc_api_fields.py new file mode 100644 index 0000000..3972b1d --- /dev/null +++ b/test/asyncio/test_snc_api_fields.py @@ -0,0 +1,207 @@ +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from ..constants import Constants +from pysnc.record import GlideElement + + +class TestAsyncRecordFields(IsolatedAsyncioTestCase): + c = Constants() + + async def asyncSetUp(self): + self.client = AsyncServiceNowClient(self.c.server, self.c.credentials) + + async def asyncTearDown(self): + await self.client.session.aclose() + self.client = None + + async def test_field_limit(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = 'sys_id,name' + r = await gr.get('6816f79cc0a8016401c5a33be04be441') + + print(gr.serialize()) + self.assertEqual(r, True) + sobj = gr.serialize() + self.assertIn('sys_id', sobj) + self.assertNotIn('sys_created_on', sobj) + + async def test_field_limit_query(self): + gr = await self.client.GlideRecord('sys_user') + gr.limit = 1 + gr.fields = 'sys_id,name' + await gr.query() + await gr.next() + + print(gr.serialize()) + sobj = gr.serialize() + self.assertIn('sys_id', sobj) + self.assertNotIn('sys_created_on', sobj) + + async def test_field_bool(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = 'sys_id,active' + await gr.get('6816f79cc0a8016401c5a33be04be441') + + print(gr.serialize()) + self.assertEqual(gr.active, 'true') + + async def test_field_access(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = 'sys_id,name' + await gr.get('6816f79cc0a8016401c5a33be04be441') + + print(gr.serialize()) + + name = 'System Administrator' + self.assertEqual(gr.name, name) + self.assertEqual(gr.get_value('name'), name) + self.assertEqual(gr.get_display_value('name'), name) + + async def test_field_contains(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = 'sys_id,name' + await gr.get('6816f79cc0a8016401c5a33be04be441') + print(gr.serialize()) + self.assertTrue('name' in gr) + self.assertFalse('whatever' in gr) + + async def test_field_set(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = 'sys_id,name' + await gr.get('6816f79cc0a8016401c5a33be04be441') + print(gr.serialize()) + name = 'System Administrator' + self.assertEqual(gr.name, name) + gr.name = 'Whatever' + self.assertEqual(gr.name, 'Whatever') + gr.set_value('name', 'Test') + self.assertEqual(gr.name, 'Test') + self.assertEqual(gr.get_value('name'), 'Test') + + async def test_field_set_init(self): + gr = await self.client.GlideRecord('sys_user') + gr.initialize() + name = 'System Administrator' + gr.name = name + self.assertEqual(gr.name, name) + gr.set_value('name', 'Test') + self.assertEqual(gr.name, 'Test') + self.assertEqual(gr.get_value('name'), 'Test') + + async def test_fields(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = ['sys_id'] + gr.limit = 4 + await gr.query() + count = 0 + while await gr.next(): + count += 1 + assert len(gr._current().keys()) == 1 + self.assertEqual(count, 4) + + async def test_field_getter(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = ['sys_id'] + self.assertEqual(gr.fields, ['sys_id']) + + async def test_field_all(self): + gr = await self.client.GlideRecord('sys_user') + self.assertIsNone(gr.fields) + await gr.query() + self.assertIsNotNone(gr.fields) + + async def test_field_getter_query(self): + gr = await self.client.GlideRecord('sys_user') + self.assertIsNone(gr.fields) + gr.limit = 1 + await gr.query() + self.assertIsNotNone(gr.fields) + self.assertGreater(len(gr.fields), 10) + await gr.next() + print(gr.fields) + self.assertGreater(len(gr.fields), 10) + + async def test_boolean(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = ['sys_id', 'active'] + await gr.query() + self.assertTrue(await gr.next()) + # as a string, because that's the actual JSON response value + self.assertEqual(gr.active, 'true') + self.assertEqual(gr.get_value('active'), 'true') + self.assertEqual(gr.get_display_value('active'), 'true') + self.assertEqual(gr.get_element('active'), 'true') + self.assertTrue(bool(gr.active)) + if not gr.active: + assert 'should have been true' + gr.active = 'false' + print(repr(gr.active)) + self.assertFalse(bool(gr.active)) + if gr.active: + assert 'should have been false' + + async def test_attrs(self): + gr = await self.client.GlideRecord('sys_user') + r = await gr.get('6816f79cc0a8016401c5a33be04be441') + self.assertEqual(r, True) + self.assertEqual(gr.sys_id, '6816f79cc0a8016401c5a33be04be441') + self.assertEqual(gr.get_value('sys_id'), '6816f79cc0a8016401c5a33be04be441') + self.assertEqual(gr.get_display_value('user_password'), '********') + + async def test_attrs_nil(self): + gr = await self.client.GlideRecord('sys_user') + r = await gr.get('6816f79cc0a8016401c5a33be04be441') + self.assertEqual(r, True) + self.assertIsNotNone(gr.get_element('sys_id')) + self.assertIsNone(gr.get_element('asdf')) + self.assertFalse(gr.get_element('sys_id').nil()) + self.assertFalse(gr.sys_id.nil()) + gr.sys_id = '' + self.assertTrue(gr.get_element('sys_id').nil()) + self.assertTrue(gr.sys_id.nil()) + + async def test_attrs_changes_existing_record(self): + gr = await self.client.GlideRecord('sys_user') + r = await gr.get('6816f79cc0a8016401c5a33be04be441') + self.assertEqual(r, True) + self.assertIsNotNone(gr.get_element('sys_id')) + self.assertIsNone(gr.get_element('asdf')) + self.assertEqual(gr.get_element('sys_id').changes(), False) + gr.sys_id = '1234' + self.assertEqual(gr.get_element('sys_id').changes(), True) + + async def test_attrs_changes_initialized(self): + gr = await self.client.GlideRecord('sys_user') + gr.initialize() + self.assertTrue(gr.is_new_record()) + self.assertIsNone(gr.get_element('sys_id')) + gr.sys_id = 'zzzz' + # not considering nothing→something a change; only subsequent edits + self.assertEqual(gr.get_element('sys_id').changes(), False) + gr.sys_id = '1234' + self.assertEqual(gr.get_element('sys_id').changes(), True) + + async def test_dotwalk_with_element(self): + gr = await self.client.GlideRecord('sys_user') + gr.fields = 'sys_id,active,email,department,department.name,department.dept_head,department.dept_head.email' + await gr.get('6816f79cc0a8016401c5a33be04be441') + print(gr.serialize(display_value='both')) + + self.assertEqual(gr.email, 'admin@example.com') + self.assertEqual(gr.department, 'a581ab703710200044e0bfc8bcbe5de8') + self.assertEqual(gr.department.name, 'Finance') + + self.assertEqual(gr.department.dept_head, '46c5bf6ca9fe1981010713e3ac7d3384') + self.assertEqual(type(gr.department.dept_head), GlideElement) + self.assertEqual(gr.department.dept_head.get_value(), '46c5bf6ca9fe1981010713e3ac7d3384') + self.assertFalse(gr.department.dept_head.nil()) + + self.assertEqual(gr.department.dept_head.email, 'natasha.ingram@example.com') + self.assertEqual(type(gr.department.dept_head.email), GlideElement) + + self.assertRaisesRegex( + AttributeError, + r'.+has no attribute.+nor GlideElement.+', + lambda: gr.department.description + ) diff --git a/test/asyncio/test_snc_api_query.py b/test/asyncio/test_snc_api_query.py new file mode 100644 index 0000000..8ceb9d9 --- /dev/null +++ b/test/asyncio/test_snc_api_query.py @@ -0,0 +1,279 @@ +# tests/asyncio/test_async_record_query.py +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from pysnc import exceptions +from ..constants import Constants + + +class TestAsyncRecordQuery(IsolatedAsyncioTestCase): + """ + TODO: active query + """ + c = Constants() + + async def test_batching(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('syslog') + gr.fields = ['sys_id'] # limit response size + await gr.query() + gr.limit = 1100 # set after first page to mimic original behavior + count = 0 + while await gr.next(): + self.assertFalse(gr.is_new_record()) + count += 1 + self.assertGreater(count, 600) + finally: + await client.session.aclose() + + async def test_query_obj(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_db_object') + qobj = gr.add_query('name', 'alm_asset') + self.assertIsNotNone(qobj) + finally: + await client.session.aclose() + + async def test_or_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_db_object') + o = gr.add_query('name', 'alm_asset') + o.add_or_condition('name', 'bsm_chart') + await gr.query() + self.assertEqual(gr.get_row_count(), 2) + finally: + await client.session.aclose() + + async def test_get_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_db_object') + o = gr.add_query('name', 'alm_asset') + o.add_or_condition('name', 'bsm_chart') + enc_query = gr.get_encoded_query() + self.assertEqual(enc_query, 'name=alm_asset^ORname=bsm_chart^ORDERBYsys_id') + finally: + await client.session.aclose() + + async def test_get_query_two(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + await gr.get('6816f79cc0a8016401c5a33be04be441') + enc_query = gr.get_encoded_query() + self.assertEqual(enc_query, 'ORDERBYsys_id') # always have default orderby + finally: + await client.session.aclose() + + async def test_null_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr_first = await client.GlideRecord('sys_user') + gr_first.fields = 'sys_id' + await gr_first.query() + + gr = await client.GlideRecord('sys_user') + gr.add_null_query('name') + await gr.query() + self.assertNotEqual(gr.get_row_count(), gr_first.get_row_count()) + finally: + await client.session.aclose() + + async def test_len(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr_first = await client.GlideRecord('sys_user') + gr_first.fields = 'sys_id' + await gr_first.query() + + gr = await client.GlideRecord('sys_user') + gr.add_null_query('name') + await gr.query() + self.assertNotEqual(len(gr), len(gr_first)) + finally: + await client.session.aclose() + + async def test_len_nonzero(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.add_not_null_query('mobile_phone') + await gr.query() + self.assertLess(len(gr), 20) + finally: + await client.session.aclose() + + async def test_not_null_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.add_not_null_query('mobile_phone') + await gr.query() + self.assertLess(gr.get_row_count(), 20) + finally: + await client.session.aclose() + + async def test_double_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.add_query('active', 'true') + gr.add_encoded_query('test=what') + query = gr.get_encoded_query() + self.assertEqual(query, "active=true^test=what^ORDERBYsys_id") + + gr = await client.GlideRecord('sys_user') + gr.add_encoded_query('test=what') + gr.add_query('active', 'true') + query = gr.get_encoded_query() + self.assertEqual(query, "active=true^test=what^ORDERBYsys_id") + finally: + await client.session.aclose() + + async def test_get_true(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertTrue(await gr.get('6816f79cc0a8016401c5a33be04be441')) + finally: + await client.session.aclose() + + async def test_get_field_true(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertTrue(await gr.get('sys_id', '6816f79cc0a8016401c5a33be04be441')) + finally: + await client.session.aclose() + + async def test_get_false(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertFalse(await gr.get('bunk')) + finally: + await client.session.aclose() + + async def test_get_field_false(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertFalse(await gr.get('sys_id', 'bunk')) + finally: + await client.session.aclose() + + async def test_no_result_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.add_query('sys_id', 'bunk') + await gr.query() + self.assertFalse(gr.has_next()) + + # Ensure we don't iterate any items + iterated = False + while await gr.next(): + iterated = True + self.assertFalse(iterated) + finally: + await client.session.aclose() + + async def test_get_field_access_direct(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertTrue(await gr.get('6816f79cc0a8016401c5a33be04be441')) + self.assertEqual(gr.user_name, 'admin') + finally: + await client.session.aclose() + + async def test_get_field_access(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + self.assertTrue(await gr.get('sys_id', '6816f79cc0a8016401c5a33be04be441')) + self.assertEqual(gr.user_name, 'admin') + finally: + await client.session.aclose() + + async def test_import(self): + from pysnc.query import Query + from pysnc.query import QueryCondition + from pysnc.query import BaseCondition + + class Junk(BaseCondition): + pass + + _ = Junk('name', 'operator') # exercise basic subclassing + + async def test_code_query_one(self): + from pysnc.query import Query + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + q = Query() + q.add_query('sys_id', '6816f79cc0a8016401c5a33be04be441') + q.add_query('second', 'asdf') + self.assertEqual(q.generate_query(), 'sys_id=6816f79cc0a8016401c5a33be04be441^second=asdf') + self.assertEqual(gr.get_encoded_query(), 'ORDERBYsys_id') + await gr.query(q) + self.assertEqual(len(gr), 1) + self.assertEqual(gr.get_encoded_query(), 'ORDERBYsys_id') + gr.add_encoded_query(q.generate_query()) + self.assertEqual( + gr.get_encoded_query(), + 'sys_id=6816f79cc0a8016401c5a33be04be441^second=asdf^ORDERBYsys_id' + ) + await gr.query() + self.assertEqual(len(gr), 1) + finally: + await client.session.aclose() + + async def test_disable_display_values(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.display_value = False + gr.limit = 1 + await gr.query() + self.assertTrue(await gr.next()) + # print(gr.serialize(display_value='all')) + self.assertFalse(gr.sys_updated_on.nil()) + ele = gr.sys_updated_on + print(repr(ele)) + self.assertEqual(ele.get_value(), ele.get_display_value(), 'expected timestamps to equal') + + gr = await client.GlideRecord('sys_user') + gr.display_value = True + gr.limit = 1 + await gr.query() + await gr.next() + self.assertNotEqual(ele.get_value(), gr.get_value('sys_updated_on')) + finally: + await client.session.aclose() + + async def test_nonjson_error(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + super_long_non_existant_name = "A" * 8000 + gr = await client.GlideRecord(super_long_non_existant_name) + with self.assertRaisesRegex(exceptions.RequestException, r'Invalid table'): + await gr.get('doesntmatter') + finally: + await client.session.aclose() + + async def test_changes(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.limit = 1 + await gr.query() + self.assertTrue(await gr.next()) + self.assertFalse(gr.changes()) + gr.user_name = 'new name' + self.assertTrue(gr.changes()) + finally: + await client.session.aclose() diff --git a/test/asyncio/test_snc_api_query_advanced.py b/test/asyncio/test_snc_api_query_advanced.py new file mode 100644 index 0000000..2ffca90 --- /dev/null +++ b/test/asyncio/test_snc_api_query_advanced.py @@ -0,0 +1,70 @@ +# tests/asyncio/test_async_record_query_advanced.py +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from ..constants import Constants + + +class TestAsyncRecordQueryAdvanced(IsolatedAsyncioTestCase): + c = Constants() + + async def test_join_query(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + join_query = gr.add_join_query('sys_user_group', join_table_field='manager') + join_query.add_query('active', 'true') + self.assertEqual( + gr.get_encoded_query(), + 'JOINsys_user.sys_id=sys_user_group.manager!active=true' + ) + await gr.query() + self.assertGreater(gr.get_row_count(), 1) + finally: + await client.session.aclose() + + async def test_join_query_2(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + join_query = gr.add_join_query('sys_user_has_role', join_table_field='user') + join_query.add_query('role', '2831a114c611228501d4ea6c309d626d') + self.assertEqual( + gr.get_encoded_query(), + 'JOINsys_user.sys_id=sys_user_has_role.user!role=2831a114c611228501d4ea6c309d626d' + ) + await gr.query() + await gr.next() + # demo data has a lot of admins, but not *that* many + self.assertGreater(len(gr), 10) + self.assertLess(len(gr), 25) + finally: + await client.session.aclose() + + async def test_rl_query_manual(self): + # simulate a left outer join by finding groups with admin-like roles + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user_group') + gr.add_encoded_query('RLQUERYsys_group_has_role.group,>0,m2m^role.nameLIKEadmin^ENDRLQUERY') + await gr.query() + self.assertGreater(gr.get_row_count(), 2) + self.assertLess(gr.get_row_count(), 8) + finally: + await client.session.aclose() + + async def test_rl_query_advanced(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user_group') + qc = gr.add_rl_query('sys_group_has_role', 'group', '>0', True) + qc.add_query('role.name', 'LIKE', 'admin') + self.assertEqual( + gr.get_encoded_query(), + 'RLQUERYsys_group_has_role.group,>0,m2m^role.nameLIKEadmin^ENDRLQUERY' + ) + await gr.query() + self.assertGreater(gr.get_row_count(), 2) + self.assertLess(gr.get_row_count(), 8) + finally: + await client.session.aclose() diff --git a/test/asyncio/test_snc_api_write.py b/test/asyncio/test_snc_api_write.py new file mode 100644 index 0000000..bbdca92 --- /dev/null +++ b/test/asyncio/test_snc_api_write.py @@ -0,0 +1,329 @@ +# tests/asyncio/test_async_api_write.py +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from ..constants import Constants + + +class TestAsyncWrite(IsolatedAsyncioTestCase): + c = Constants() + + async def test_crud(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.initialize() + gr.short_description = "Unit Test - Insert" + gr.description = "Second Field" + gr.bunk_field = "Bunk Field" + res = await gr.insert() + self.assertIsNotNone(res) + # should have gotten the response back, ergo populated new fields + self.assertIsNotNone(gr.opened_by) + self.assertEqual(len(gr.opened_by), 32, 'expected opened_by to be a sys_id') + self.assertNotEqual(gr.get_value('opened_by'), gr.get_display_value('opened_by')) + first_user_display = gr.get_display_value('opened_by') + + # We have validated inserting works, now can we update. + user = await client.GlideRecord('sys_user') + self.assertTrue(await user.get('26fbff173b331300ad3cc9bb34efc4bd')) # problem.admin + self.assertNotEqual(user.sys_id, gr.get_value('opened_by')) + + # actually update + gr2 = await client.GlideRecord('problem') + self.assertTrue(await gr2.get(res)) + self.assertTrue(bool(gr2.active)) + gr2.short_description = "ABCDEFG0123" + self.assertTrue(gr2.changes()) + self.assertEqual(gr2.get_value('short_description'), "ABCDEFG0123") + gr2.assigned_to = user.sys_id + self.assertIsNotNone(await gr2.update()) + + # local record updated + self.assertTrue(bool(gr2.active)) + self.assertEqual(gr2.short_description, 'ABCDEFG0123') + self.assertEqual(gr2.assigned_to, user.sys_id) + self.assertNotEqual(gr2.get_display_value('assigned_to'), first_user_display) + self.assertEqual(gr2.get_display_value('assigned_to'), user.get_display_value('name')) + + # re-query + gr3 = await client.GlideRecord('problem') + await gr3.get(res) + self.assertEqual(gr3.short_description, "ABCDEFG0123") + self.assertEqual(gr3.get_display_value('assigned_to'), user.get_display_value('name')) + + gr4 = gr3.pop_record() + gr4.short_description = 'ZZZ123' + self.assertTrue(await gr4.update()) + + gr4 = gr3.pop_record() + gr4.short_description = 'ZZZ123' + self.assertTrue(await gr4.update()) + + gr4 = gr3.pop_record() + gr4.short_description = 'ZZZ123' + self.assertTrue(await gr4.update()) + + self.assertTrue(await gr3.delete()) + + # make sure it is deleted + gr4 = await client.GlideRecord('problem') + self.assertFalse(await gr4.get(res)) + finally: + await client.session.aclose() + + async def test_insert(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + # I want to ensure the records sys_id is updated + gr = await client.GlideRecord('problem') + gr.initialize() + gr.short_description = "Unit Test - Test insert id update" + self.assertIsNone(gr.sys_id) + res = await gr.insert() + self.assertIsNotNone(res) + self.assertIsNotNone(gr.sys_id) + self.assertEqual(res, gr.sys_id) + self.assertIsNotNone(gr.number) + + # make sure it exists + gr2 = await client.GlideRecord('problem') + self.assertTrue(await gr2.get(res)) + self.assertEqual(gr2.number, gr.number) + + await gr.delete() + + # make sure it is deleted + gr4 = await client.GlideRecord('problem') + self.assertFalse(await gr4.get(res)) + finally: + await client.session.aclose() + + async def test_insert_custom_guid(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + customsysid = 'AAAABBBBCCCCDDDDEEEEFFFF00001111' + # make sure this id doesn't exist, first + gr = await client.GlideRecord('problem') + if await gr.get(customsysid): + await gr.delete() + + gr = await client.GlideRecord('problem') + gr.initialize() + gr.set_new_guid_value(customsysid) + gr.short_description = "Unit Test - Test insert id update" + res = await gr.insert() + self.assertIsNotNone(res) + self.assertIsNotNone(gr.sys_id) + self.assertEqual(res, customsysid) + + # make sure it exists + gr2 = await client.GlideRecord('problem') + self.assertTrue(await gr2.get(customsysid)) + + await gr.delete() + + # make sure it is deleted + gr4 = await client.GlideRecord('problem') + self.assertFalse(await gr4.get(res)) + finally: + await client.session.aclose() + + async def test_object_setter(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.initialize() + gr.name = 'aaaa' + self.assertEqual(gr.name, 'aaaa') + gr.roles = [1, 2, 3] + self.assertEqual(gr.roles, [1, 2, 3]) + finally: + await client.session.aclose() + + async def test_object_secondary_field(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + gr.limit = 1 + await gr.query() + self.assertTrue(await gr.next()) + gr.boom = 'aaaa' + self.assertEqual(gr.boom, 'aaaa') + gr.bye = [1, 2, 3] + self.assertEqual(gr.bye, [1, 2, 3]) + finally: + await client.session.aclose() + + async def test_multi_delete(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.delete_multiple() # ensure none to start + + # insert five bunk records + for i in range(5): + ngr = await client.GlideRecord('problem') + ngr.initialize() + ngr.short_description = f"Unit Test - BUNKZZ Multi Delete {i}" + self.assertTrue(await ngr.insert(), "Failed to insert a record") + + # now make sure they exist... + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.query() + self.assertEqual(len(gr), 5) + + # now multi delete... + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + self.assertTrue(await gr.delete_multiple()) + + # check again + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.query() + self.assertEqual(len(gr), 0) + finally: + await client.session.aclose() + + async def test_multi_update(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.query() + await gr.delete_multiple() # clear out + await gr.query() + self.assertEqual(len(gr), 0) + + total_count = 10 + # insert records + for i in range(total_count): + ngr = await client.GlideRecord('problem') + ngr.initialize() + ngr.short_description = f"Unit Test - BUNKZZ Multi Delete {i}" + self.assertTrue(await ngr.insert(), "Failed to insert a record") + + # verify they exist + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.query() + self.assertEqual(len(gr), total_count) + + # ensure 'APPENDEDZZ' not present yet + tgr = await client.GlideRecord('problem') + tgr.add_query('short_description', 'LIKE', 'APPENDEDZZ') + await tgr.query() + self.assertEqual(len(tgr), 0) + + # stage updates + while await gr.next(): + gr.short_description = gr.short_description + ' -- APPENDEDZZ' + await gr.update_multiple() + + # verify all appended + tgr = await client.GlideRecord('problem') + tgr.add_query('short_description', 'LIKE', 'APPENDEDZZ') + await tgr.query() + self.assertEqual(len(tgr), total_count) + + # change only even-indexed + expected_to_change = [] + i = 0 + while await tgr.next(): + r = tgr + if i % 2 == 0: + r.short_description = r.short_description + ' even' + expected_to_change.append(r.get_value('sys_id')) + self.assertTrue(r.changes()) + else: + self.assertFalse(r.changes()) + i += 1 + + saw_change = [] + + def custom_handle(response): + nonlocal saw_change + self.assertEqual(response.status_code, 200) + saw_change.append(response.json()['result']['sys_id']) + + await tgr.update_multiple(custom_handle) + self.assertCountEqual(saw_change, expected_to_change) + self.assertListEqual(saw_change, expected_to_change) + + await tgr.delete_multiple() + finally: + await client.session.aclose() + + async def test_multi_update_with_failures(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + br = await client.GlideRecord('sys_script') + + # create BR once if missing + br.add_query('name', 'test_multi_update_with_failures') + await br.query() + if not await br.next(): + br.initialize() + br.name = 'test_multi_update_with_failures' + br.collection = 'problem' + br.active = True + br.when = 'before' + br.order = 100 + br.action_insert = True + br.action_update = True + br.abort_action = True + br.add_message = True + br.message = 'rejected by test_multi_update_with_failures br' + br.filter_condition = 'short_descriptionLIKEEICAR^ORdescriptionLIKEEICAR^EQ' + await br.insert() + + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.query() + self.assertTrue(await gr.delete_multiple()) # clear + await gr.query() + self.assertEqual(len(gr), 0, 'should have had none left') + + total_count = 10 + # insert records + for i in range(total_count): + ngr = await client.GlideRecord('problem') + ngr.initialize() + ngr.short_description = f"Unit Test - BUNKZZ Multi update {i}" + self.assertTrue(await ngr.insert(), "Failed to insert a record") + + gr = await client.GlideRecord('problem') + gr.add_query('short_description', 'LIKE', 'BUNKZZ') + await gr.query() + self.assertEqual(len(gr), total_count) + + # half append + i = 0 + while i < (total_count // 2) and await gr.next(): + gr.short_description = gr.short_description + ' -- APPENDEDZZ' + i += 1 + # half error + while await gr.next(): + gr.short_description = gr.short_description + ' -- EICAR' + + self.assertFalse(await gr.update_multiple()) + + # make sure we cleaned up as expected (name-mangled attrs) + self.assertEqual(gr._client.batch_api._AsyncBatchAPI__hooks, {}) + self.assertEqual(gr._client.batch_api._AsyncBatchAPI__stored_requests, {}) + self.assertEqual(gr._client.batch_api._AsyncBatchAPI__requests, []) + + tgr = await client.GlideRecord('problem') + tgr.add_query('short_description', 'LIKE', 'APPENDEDZZ') + await tgr.query() + self.assertEqual(len(tgr), total_count // 2) + + tgr = await client.GlideRecord('problem') + tgr.add_query('short_description', 'LIKE', 'EICAR') + await tgr.query() + self.assertEqual(len(tgr), 0) + finally: + await client.session.aclose() diff --git a/test/asyncio/test_snc_attachment.py b/test/asyncio/test_snc_attachment.py new file mode 100644 index 0000000..afd48ae --- /dev/null +++ b/test/asyncio/test_snc_attachment.py @@ -0,0 +1,141 @@ +from typing import Any, Dict, Optional +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient + +from ..constants import Constants + + +class AsyncTempTestRecord: + """Minimal async version of TempTestRecord used in sync tests.""" + + def __init__(self, client, table: str, default_data: Optional[Dict[str, Any]] = None): + self._client = client + self._table = table + self._data = default_data or {} + self._gr = None + + async def __aenter__(self): + self._gr = await self._client.GlideRecord(self._table) + self._gr.initialize() # local, no I/O + for k, v in self._data.items(): + self._gr.set_value(k, v) # local, no I/O + await self._gr.insert() # network I/O + return self._gr + + async def __aexit__(self, exc_type, exc_val, exc_tb): + try: + if self._gr and getattr(self._gr, "sys_id", None): + await self._gr.delete() # network I/O + finally: + self._gr = None + # Do not suppress exceptions + return False + + +class TestAsyncAttachment(IsolatedAsyncioTestCase): + c = Constants() + + async def _deleteOrCreateTestRecord(self, short_description: str): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord("problem") + gr.add_query("short_description", short_description) + await gr.query() + if await gr.next(): + return gr + gr.initialize() + gr.short_description = short_description + gr.description = "Second Field" + await gr.insert() + return gr + finally: + await client.session.aclose() + + async def _getOrCreateEmptyTestRecord(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord("problem") + gr.add_query("short_description", "Unit Test - Attachments") + await gr.query() + if await gr.next(): + return gr + gr.initialize() + gr.short_description = "Unit Test - Attachments" + gr.description = "Second Field" + await gr.insert() + return gr + finally: + await client.session.aclose() + + async def test_attachments_for(self): + gr = await self._getOrCreateEmptyTestRecord() + attachments = await gr.get_attachments() + self.assertIsNotNone(attachments) + self.assertEqual(len(attachments), 0) + + async def test_add_delete_get(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + async with AsyncTempTestRecord(client, "problem") as gr: + self.assertIsNotNone(gr.sys_id) + + attachments = await gr.get_attachments() + self.assertIsNotNone(attachments) + self.assertEqual(len(attachments), 0) + + content = "this is a sample attachment\nwith\nmulti\nlines" + test_url = await gr.add_attachment("test.txt", content) + self.assertIsNotNone(test_url, "expected the location of test.txt") + + attachments = await gr.get_attachments() + self.assertEqual(len(attachments), 1) + await attachments.next() + self.assertEqual(attachments.get_link(), test_url) + + test_txt_sys_id = attachments.sys_id + + bcontent = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09" + await gr.add_attachment("test.bin", bcontent) + + attachments = await gr.get_attachments() + self.assertEqual(len(attachments), 2) + + tgr = await client.GlideRecord(gr.table) + assert await tgr.get(gr.sys_id), "could not re-query the table?" + self.assertEqual(len(await tgr.get_attachments()), 2, "Could not see attachments on re-query?") + + # iterate over attachments (async) + async for a in attachments: + self.assertTrue(a.file_name.startswith("test"), f"expected a test file, not {a.file_name}") + if a.file_name.endswith("txt"): + self.assertEqual(a.file_name, "test.txt") + lines = await a.readlines() + self.assertEqual(lines[0], "this is a sample attachment") + self.assertEqual(len(lines), 4) + if a.file_name.endswith("bin"): + self.assertEqual(a.file_name, "test.bin") + raw = await a.read() + self.assertEqual(raw, bcontent, "binary content did not match") + + # get + problem_attachment = await client.Attachment("problem") + await problem_attachment.get(test_txt_sys_id) + self.assertEqual(problem_attachment.get_link(), test_url) + self.assertEqual(problem_attachment.sys_id, test_txt_sys_id) + self.assertEqual((await problem_attachment.read()).decode("ascii"), content) + + # list (no results) + problem_attachment = await client.Attachment("problem") + problem_attachment.add_query("file_name", "thisdoesntexist918") + await problem_attachment.query() + self.assertFalse(await problem_attachment.next()) + + # list (find specific) + problem_attachment = await client.Attachment("problem") + problem_attachment.add_query("file_name", "test.txt") + await problem_attachment.query() + self.assertTrue(await problem_attachment.next()) + self.assertEqual(problem_attachment.sys_id, test_txt_sys_id) + finally: + await client.session.aclose() diff --git a/test/asyncio/test_snc_auth.py b/test/asyncio/test_snc_auth.py new file mode 100644 index 0000000..245fd38 --- /dev/null +++ b/test/asyncio/test_snc_auth.py @@ -0,0 +1,80 @@ +# tests/asyncio/test_async_auth.py +from unittest import IsolatedAsyncioTestCase, skip + +from pysnc.asyncio import AsyncServiceNowClient +from pysnc.asyncio.auth import AsyncServiceNowPasswordGrantFlow, AsyncServiceNowJWTAuth # noqa: F401 (used in nop test) +from pysnc import exceptions +from ..constants import Constants + + +class TestAsyncAuth(IsolatedAsyncioTestCase): + c = Constants() + + async def test_basic(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_user') + print(gr.fields) + gr.fields = 'sys_id' + print(gr.fields) + self.assertTrue(await gr.get('6816f79cc0a8016401c5a33be04be441')) + finally: + await client.session.aclose() + + async def test_basic_fail(self): + client = AsyncServiceNowClient(self.c.server, ('admin', 'this is not a real password')) + try: + gr = await client.GlideRecord('sys_user') + try: + await gr.get('does not matter') + self.fail('Exception should have been thrown') + except exceptions.AuthenticationException as e: + self.assertTrue('not authenticated' in str(e).lower()) + self.assertTrue('required to provide auth information' in str(e).lower()) + except Exception: + self.fail('Should have got an Auth exception') + finally: + await client.session.aclose() + + @skip("Requires valid oauth client_id and secret, and I don't want to need anything not out of box") + async def test_oauth(self): + # Manual setup using legacy oauth (async) + creds = self.c.credentials + client_id = self.c.get_value('CLIENT_ID') + secret = self.c.get_value('CLIENT_SECRET') + + flow = AsyncServiceNowPasswordGrantFlow(creds[0], creds[1], client_id, secret) + # If your AsyncServiceNowClient accepts an httpx.AsyncClient, you can do: + # headers = await flow.authenticate(self.c.server, verify=True) + # client = httpx.AsyncClient(base_url=self.c.server, headers={"Accept": "application/json", **headers}) + # aclient = AsyncServiceNowClient(self.c.server, client) + # Otherwise, if you wired flow support directly into AsyncServiceNowClient, use that instead. + + aclient = AsyncServiceNowClient(self.c.server, flow) # only if your client supports flows + try: + gr = await aclient.GlideRecord('sys_user') + gr.fields = 'sys_id' + self.assertTrue(await gr.get('6816f79cc0a8016401c5a33be04be441')) + finally: + await aclient.session.aclose() + + async def test_auth_param_check(self): + with self.assertRaisesRegex(exceptions.AuthenticationException, r'Cannot specify both.+'): + AsyncServiceNowClient('anyinstance', auth='asdf', cert='asdf') + with self.assertRaisesRegex(exceptions.AuthenticationException, r'No valid auth.+'): + AsyncServiceNowClient('anyinstance', auth='zzz') + + def nop_test_jwt(self): + """ + we act as our own client here, which you should not do. + + Example async usage: + + auth = AsyncServiceNowJWTAuth(client_id, client_secret, jwt) + client = AsyncServiceNowClient(self.c.server, auth) + + gr = await client.GlideRecord('sys_user') + gr.fields = 'sys_id' + assert await gr.get('6816f79cc0a8016401c5a33be04be441'), "did not jwt auth" + """ + pass diff --git a/test/asyncio/test_snc_batching.py b/test/asyncio/test_snc_batching.py new file mode 100644 index 0000000..e05763c --- /dev/null +++ b/test/asyncio/test_snc_batching.py @@ -0,0 +1,83 @@ +# tests/asyncio/test_snc_api_batching.py +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from ..constants import Constants + + +class TestAsyncBatching(IsolatedAsyncioTestCase): + c = Constants() + + async def test_batch_multi(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id' + gr.batch_size = 3 + gr.limit = 9 + await gr.query() + + res = [r.sys_id async for r in gr] + self.assertEqual(len(res), 9) + finally: + await client.session.aclose() + + async def test_batch_multi_uneven(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id' + gr.batch_size = 3 + gr.limit = 7 + await gr.query() + + res = [r.sys_id async for r in gr] + self.assertEqual(len(res), 7) + finally: + await client.session.aclose() + + async def test_batch_actual(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id' + gr.batch_size = 3 + await gr.query() + await gr.next() + # Accessing the same mangled attr as sync (inherited storage) + self.assertEqual(len(gr._GlideRecord__results), 3) + finally: + await client.session.aclose() + + async def test_default_limit(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.add_active_query() + + params = gr._parameters() + self.assertEqual(params['sysparm_limit'], 100, "default batch size is not 100?") + + gr.limit = 400 + params = gr._parameters() + self.assertIn('sysparm_limit', params) + self.assertEqual( + params['sysparm_limit'], 100, + "batch size still 100 if we have a limit over batch size" + ) + finally: + await client.session.aclose() + + async def test_default_order(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + + self.assertEqual(gr._parameters()['sysparm_query'], 'ORDERBYsys_id') + gr.order_by('number') + self.assertEqual(gr._parameters()['sysparm_query'], 'ORDERBYnumber') + + gr.order_by(None) + self.assertEqual(gr._parameters()['sysparm_query'], 'ORDERBYsys_id') + finally: + await client.session.aclose() diff --git a/test/asyncio/test_snc_iteration.py b/test/asyncio/test_snc_iteration.py new file mode 100644 index 0000000..edc8b61 --- /dev/null +++ b/test/asyncio/test_snc_iteration.py @@ -0,0 +1,62 @@ +# tests/asyncio/test_snc_iteration.py +from unittest import IsolatedAsyncioTestCase + +from pysnc.asyncio import AsyncServiceNowClient +from ..constants import Constants + + +class TestAsyncIteration(IsolatedAsyncioTestCase): + c = Constants() + + async def test_default_behavior(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_metadata', batch_size=100) + gr.fields = 'sys_id' + gr.limit = 500 + await gr.query() + self.assertTrue(gr._is_rewindable()) + + self.assertTrue(len(gr) > 500, 'Expected more than 500 records') + + count = 0 + while await gr.next(): + count += 1 + self.assertEqual(count, 500, 'Expected 500 records when using next') + + self.assertEqual(len([r.sys_id async for r in gr]), 500, 'Expected 500 records when an iterable') + self.assertEqual(len([r.sys_id async for r in gr]), 500, 'Expected 500 records when iterated again') + + # expect the same for next + count = 0 + while await gr.next(): + count += 1 + self.assertEqual(count, 0, 'Expected 0 records when not rewound, as next does not auto-rewind') + gr.rewind() + while await gr.next(): + count += 1 + self.assertEqual(count, 500, 'Expected 500 post rewind') + + # should not throw + await gr.query() + await gr.query() + finally: + await client.session.aclose() + + async def test_rewind_behavior(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('sys_metadata', batch_size=250, rewindable=False) + gr.fields = 'sys_id' + gr.limit = 500 + await gr.query() + self.assertEqual(gr._GlideRecord__current, -1) + self.assertFalse(gr._is_rewindable()) + self.assertEqual(len([r async for r in gr]), 500, 'Expected 500 records when an iterable') + self.assertEqual(len([r async for r in gr]), 0, 'Expected no records when iterated again') + + # but if we query again... + with self.assertRaises(RuntimeError): + await gr.query() + finally: + await client.session.aclose() diff --git a/test/asyncio/test_snc_serialization.py b/test/asyncio/test_snc_serialization.py new file mode 100644 index 0000000..d6f78d3 --- /dev/null +++ b/test/asyncio/test_snc_serialization.py @@ -0,0 +1,247 @@ +# tests/asyncio/test_snc_serialization.py +from unittest import IsolatedAsyncioTestCase +from types import SimpleNamespace + +from pysnc.asyncio import AsyncServiceNowClient +from ..constants import Constants +from pysnc.record import GlideRecord # used for local (no-network) serialization tests + + +class TestAsyncSerialization(IsolatedAsyncioTestCase): + c = Constants() + + async def test_pandas_smart(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + + # print(await gr.serialize_all(display_value='both')) + + data = await gr.to_pandas() + self.assertIsInstance(data, dict) + self.assertIn('sys_id', data) + self.assertIn('short_description', data) + self.assertIn('state__value', data) + self.assertIn('state__display', data) + self.assertEqual(len(data['sys_id']), 4) + self.assertEqual(len(data['short_description']), 4) + self.assertEqual(len(data['state__value']), 4) + self.assertEqual(len(data['state__display']), 4) + finally: + await client.session.aclose() + + async def test_pandas_both(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + + # print(await gr.serialize_all(display_value='both')) + + data = await gr.to_pandas(mode='both') + self.assertIsInstance(data, dict) + self.assertIn('sys_id__value', data) + self.assertIn('short_description__display', data) + self.assertIn('state__value', data) + self.assertIn('state__display', data) + self.assertEqual(len(data['sys_id__value']), 4) + self.assertEqual(len(data['short_description__display']), 4) + self.assertEqual(len(data['state__value']), 4) + self.assertEqual(len(data['state__display']), 4) + finally: + await client.session.aclose() + + async def test_pandas_value(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + + # print(await gr.serialize_all(display_value='both')) + + data = await gr.to_pandas(mode='value') + self.assertIsInstance(data, dict) + self.assertIn('sys_id', data) + self.assertIn('short_description', data) + self.assertIn('state', data) + self.assertNotIn('state__value', data) + self.assertEqual(len(data['sys_id']), 4) + finally: + await client.session.aclose() + + async def test_pandas_order_cols(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + + # print(await gr.serialize_all(display_value='both')) + + data = await gr.to_pandas() + self.assertListEqual(list(data.keys()), ['sys_id', 'short_description', 'state__value', 'state__display']) + data = await gr.to_pandas(mode='display') + self.assertListEqual(list(data.keys()), ['sys_id', 'short_description', 'state']) + data = await gr.to_pandas(columns=['jack', 'jill', 'hill'], mode='display') + self.assertListEqual(list(data.keys()), ['jack', 'jill', 'hill']) + finally: + await client.session.aclose() + + async def test_serialize_all_batch(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.batch_size = 3 + gr.limit = 9 + await gr.query() + + records = await gr.serialize_all() + self.assertEqual(len(records), 9) + finally: + await client.session.aclose() + + async def test_serialize_noncurrent(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + self.assertIsNone(gr.serialize()) + await gr.next() + self.assertIsNotNone(gr.serialize()) + finally: + await client.session.aclose() + + async def test_serialize_changes(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + await gr.next() + data = gr.serialize() + self.assertIsNotNone(data) + self.assertListEqual(list(data.keys()), ['sys_id', 'short_description', 'state']) + self.assertListEqual(list(gr.serialize(changes_only=True).keys()), []) + gr.short_description = 'new' + self.assertListEqual(list(gr.serialize(changes_only=True).keys()), ['short_description']) + finally: + await client.session.aclose() + + # -------- no-network serialization tests (same as sync) -------- + + async def test_serialize(self): + gr = GlideRecord(None, 'some_table') + gr.initialize() + gr.strfield = 'my string' + gr.set_display_value('strfield', 'my string display value') + gr.intfield = 5 + data = gr.serialize() + self.assertIsNotNone(data) + self.assertEqual(data, {'intfield': 5, 'strfield': 'my string'}) + + async def test_serialize_display(self): + gr = GlideRecord(None, 'some_table') + gr.initialize() + gr.strfield = 'my string' + gr.set_display_value('strfield', 'my string display value') + gr.intfield = 5 + data = gr.serialize(display_value=True) + self.assertIsNotNone(data) + self.assertEqual(gr.get_value('strfield'), 'my string') + self.assertEqual(gr.get_display_value('strfield'), 'my string display value') + self.assertEqual(gr.serialize(), {'intfield': 5, 'strfield': 'my string'}) + self.assertEqual(data, {'intfield': 5, 'strfield': 'my string display value'}) + + async def test_serialize_reference_link(self): + gr = GlideRecord(None, 'some_table') + gr.initialize() + gr.reffield = 'my reference' + gr.set_link('reffield', 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') + gr._client = SimpleNamespace(instance=self.c.server) + + data = gr.serialize(exclude_reference_link=False) + self.assertIsNotNone(data) + self.assertEqual(gr.get_value('reffield'), 'my reference') + self.assertTrue(gr.get_link(True).endswith('/some_table.do?sys_id=-1'), f"was {gr.get_link()}") + self.assertEqual(gr.reffield.get_link(), 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') + self.assertEqual( + gr.serialize(exclude_reference_link=False), + {'reffield': {'value': 'my reference', 'link': 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} + ) + self.assertEqual( + data, + {'reffield': {'value': 'my reference', 'link': 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} + ) + + gr.reffield.set_link('https://dev00000.service-now.com/api/now/table/sys___/xyz789') + self.assertEqual(gr.reffield.get_link(), 'https://dev00000.service-now.com/api/now/table/sys___/xyz789') + + async def test_serialize_reference_link_all(self): + gr = GlideRecord(None, 'some_table') + gr.initialize() + gr.reffield = 'my reference' + gr.set_link('reffield', 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') + gr.set_display_value('reffield', 'my reference display') + + self.assertEqual(gr.get_value('reffield'), 'my reference') + self.assertEqual(gr.get_display_value('reffield'), 'my reference display') + self.assertEqual(gr.reffield.get_link(), 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') + + self.assertEqual(gr.serialize(), {'reffield': 'my reference'}) + self.assertEqual( + gr.serialize(exclude_reference_link=False), + {'reffield': {'value': 'my reference', 'link': 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} + ) + self.assertEqual( + gr.serialize(display_value=True, exclude_reference_link=False), + {'reffield': {'display_value': 'my reference display', 'link': 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} + ) + self.assertEqual( + gr.serialize(display_value='both', exclude_reference_link=False), + {'reffield': {'value': 'my reference', 'display_value': 'my reference display', 'link': 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} + ) + + async def test_str(self): + gr = GlideRecord(None, 'some_table') + gr.initialize() + gr.strfield = 'my string' + gr.set_display_value('strfield', 'my string display value') + gr.intfield = 5 + data = str(gr) + self.assertIsNotNone(data) + self.assertTrue(data.startswith('some_table')) + self.assertIn('my string', data) + self.assertIn('intfield', data) + + async def test_serialize_all(self): + client = AsyncServiceNowClient(self.c.server, self.c.credentials) + try: + gr = await client.GlideRecord('problem') + gr.fields = 'sys_id,short_description,state' + gr.limit = 4 + await gr.query() + data = await gr.serialize_all() + self.assertEqual(len(data), 4) + for prb in data: + self.assertEqual(list(prb.keys()), ['sys_id', 'short_description', 'state']) + + # value-only serialization should not include link objects + data = await gr.serialize_all(exclude_reference_link=False) + self.assertIsInstance(data[0]['short_description'], str) + + # (optional TODO parity case kept commented as in the original) + # data = await gr.serialize_all(display_value='both', exclude_reference_link=False) + # self.assertEqual(type(data[0]['short_description']), dict) + finally: + await client.session.aclose() diff --git a/test/test_pebcak.py b/test/test_pebcak.py index c0aed5c..a198e31 100644 --- a/test/test_pebcak.py +++ b/test/test_pebcak.py @@ -2,7 +2,7 @@ from pysnc import ServiceNowClient from pysnc.exceptions import * -from constants import Constants +from .constants import Constants class TestPEBCAK(TestCase): diff --git a/test/test_snc_api.py b/test/test_snc_api.py index 914b4cd..cc81573 100644 --- a/test/test_snc_api.py +++ b/test/test_snc_api.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient, exceptions -from constants import Constants +from .constants import Constants class TestAuditScoped(TestCase): c = Constants() diff --git a/test/test_snc_api_fields.py b/test/test_snc_api_fields.py index d7c0ef5..4e37387 100644 --- a/test/test_snc_api_fields.py +++ b/test/test_snc_api_fields.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient -from constants import Constants +from .constants import Constants from pysnc.record import GlideElement class TestRecordFields(TestCase): diff --git a/test/test_snc_api_query.py b/test/test_snc_api_query.py index e9637e6..3294a73 100644 --- a/test/test_snc_api_query.py +++ b/test/test_snc_api_query.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient, exceptions -from constants import Constants +from .constants import Constants class TestRecordQuery(TestCase): """ diff --git a/test/test_snc_api_query_advanced.py b/test/test_snc_api_query_advanced.py index 41d24bf..8aa767e 100644 --- a/test/test_snc_api_query_advanced.py +++ b/test/test_snc_api_query_advanced.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient, exceptions -from constants import Constants +from .constants import Constants class TestRecordQueryAdvanced(TestCase): c = Constants() diff --git a/test/test_snc_api_write.py b/test/test_snc_api_write.py index c6ebc58..0f31a8a 100644 --- a/test/test_snc_api_write.py +++ b/test/test_snc_api_write.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient -from constants import Constants +from .constants import Constants class TestWrite(TestCase): diff --git a/test/test_snc_attachment.py b/test/test_snc_attachment.py index d96f600..d1e6fae 100644 --- a/test/test_snc_attachment.py +++ b/test/test_snc_attachment.py @@ -1,8 +1,8 @@ from unittest import TestCase from pysnc import ServiceNowClient -from constants import Constants -from utils import TempTestRecord +from .constants import Constants +from .utils import TempTestRecord class TestAttachment(TestCase): diff --git a/test/test_snc_auth.py b/test/test_snc_auth.py index 42d1c93..d1d2415 100644 --- a/test/test_snc_auth.py +++ b/test/test_snc_auth.py @@ -2,7 +2,7 @@ from pysnc import ServiceNowClient from pysnc.auth import * -from constants import Constants +from .constants import Constants from pysnc import exceptions import requests @@ -24,8 +24,8 @@ def test_basic_fail(self): gr.get('does not matter') assert 'Exception should have been thrown' except exceptions.AuthenticationException as e: - self.assertTrue('User Not Authenticated' in str(e)) - self.assertTrue('Required to provide Auth information' in str(e)) + self.assertTrue('not authenticated' in str(e).lower()) + self.assertTrue('required to provide auth information' in str(e).lower()) except Exception: assert 'Should have got an Auth exception' diff --git a/test/test_snc_batching.py b/test/test_snc_batching.py index b02b260..63c129e 100644 --- a/test/test_snc_batching.py +++ b/test/test_snc_batching.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient -from constants import Constants +from .constants import Constants from pprint import pprint class TestBatching(TestCase): diff --git a/test/test_snc_iteration.py b/test/test_snc_iteration.py index 9b45aaa..e91e298 100644 --- a/test/test_snc_iteration.py +++ b/test/test_snc_iteration.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient -from constants import Constants +from .constants import Constants from pprint import pprint class TestIteration(TestCase): diff --git a/test/test_snc_serialization.py b/test/test_snc_serialization.py index d042b60..ef2600e 100644 --- a/test/test_snc_serialization.py +++ b/test/test_snc_serialization.py @@ -1,7 +1,7 @@ from unittest import TestCase from pysnc import ServiceNowClient -from constants import Constants +from .constants import Constants from pprint import pprint class TestSerialization(TestCase):