├── .github └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docs ├── Makefile ├── api.rst ├── conf.py ├── index.rst └── user │ ├── advanced.rst │ ├── authentication.rst │ ├── getting_started.rst │ ├── images │ ├── oauth2-01.png │ └── oauth2-02.png │ └── install.rst ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── pysnc ├── __init__.py ├── __version__.py ├── attachment.py ├── auth.py ├── client.py ├── exceptions.py ├── query.py ├── record.py └── utils.py ├── sample.env ├── test ├── constants.py ├── test_pebcak.py ├── test_snc_api.py ├── test_snc_api_fields.py ├── test_snc_api_query.py ├── test_snc_api_query_advanced.py ├── test_snc_api_write.py ├── test_snc_attachment.py ├── test_snc_auth.py ├── test_snc_batching.py ├── test_snc_element.py ├── test_snc_iteration.py ├── test_snc_serialization.py └── utils.py └── whitelist.json /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Setup Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.11" 16 | - name: cache poetry install 17 | uses: actions/cache@v2 18 | with: 19 | path: ~/.local 20 | key: poetry-1.4.2-0 21 | - name: setup poetry 22 | uses: snok/install-poetry@v1 23 | with: 24 | poetry-version: 1.4.2 25 | virtualenvs-create: true 26 | virtualenvs-in-project: true 27 | - name: cache deps 28 | id: cache-deps 29 | uses: actions/cache@v2 30 | with: 31 | path: .venv 32 | key: pydeps-${{ hashFiles('**/poetry.lock') }} 33 | - name: build and publish pypi 34 | env: 35 | PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 36 | run: | 37 | poetry config pypi-token.pypi $PYPI_TOKEN 38 | poetry version ${{ github.ref_name }} 39 | poetry publish --build 40 | poetry install --no-interaction --no-root 41 | poetry run jake ddt --output-format json -o bom.json --whitelist whitelist.json 42 | - name: update version 43 | uses: stefanzweifel/git-auto-commit-action@v4 44 | with: 45 | commit_message: Automatic version bump 46 | branch: main 47 | file_pattern: pyproject.toml 48 | - name: build docs 49 | run: | 50 | mkdir gh-pages 51 | touch gh-pages/.nojekyll 52 | cd docs 53 | poetry install --no-interaction 54 | poetry run make clean html 55 | cp -r _build/html/* ../gh-pages/ 56 | - name: publish docs 57 | uses: JamesIves/github-pages-deploy-action@4.1.4 58 | with: 59 | branch: gh-pages 60 | folder: gh-pages 61 | - name: sbom 62 | uses: svenstaro/upload-release-action@v2 63 | with: 64 | repo_token: ${{ secrets.GITHUB_TOKEN }} 65 | file: bom.json 66 | asset_name: bom.json 67 | tag: ${{ github.ref }} 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: PySNC Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main"] 7 | 8 | jobs: 9 | install-test: 10 | runs-on: ubuntu-latest 11 | if: github.repository_owner == 'ServiceNow' 12 | strategy: 13 | max-parallel: 1 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.11"] 16 | env: 17 | PYSNC_SERVER: ${{ secrets.PYSNC_SERVER }} 18 | PYSNC_USERNAME: ${{ secrets.PYSNC_USERNAME }} 19 | PYSNC_PASSWORD: ${{ secrets.PYSNC_PASSWORD }} 20 | PYSNC_CLIENT_ID: ${{ secrets.PYSNC_CLIENT_ID }} 21 | PYSNC_CLIENT_SECRET: ${{ secrets.PYSNC_CLIENT_SECRET }} 22 | steps: 23 | - name: check out repository 24 | uses: actions/checkout@v3 25 | - name: set up python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: setup poetry 30 | uses: snok/install-poetry@v1 31 | with: 32 | poetry-version: 1.4.2 33 | virtualenvs-create: true 34 | virtualenvs-in-project: true 35 | - name: cache deps 36 | id: cache-deps 37 | uses: actions/cache@v3 38 | with: 39 | path: .venv 40 | key: pydeps-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 41 | - name: install dependencies 42 | if: steps.cache-deps.outputs.cache-hit != 'true' 43 | run: poetry install --no-interaction --no-root 44 | - name: install project 45 | run: poetry install --no-interaction 46 | - name: run tests 47 | run: | 48 | poetry run pytest 49 | - name: build docs 50 | working-directory: ./docs 51 | run: poetry run make clean html 52 | - name: run extra sanity checks 53 | run: | 54 | poetry run mypy 55 | poetry run jake ddt --whitelist whitelist.json 56 | prerelease: 57 | runs-on: ubuntu-latest 58 | if: github.ref == 'refs/heads/main' 59 | needs: install-test 60 | steps: 61 | - uses: actions/checkout@v3 62 | - name: Setup Python 63 | uses: actions/setup-python@v4 64 | with: 65 | python-version: "3.11" 66 | - name: cache poetry install 67 | uses: actions/cache@v2 68 | with: 69 | path: ~/.local 70 | key: poetry-1.4.2-0 71 | - name: setup poetry 72 | uses: snok/install-poetry@v1 73 | with: 74 | poetry-version: 1.4.2 75 | virtualenvs-create: true 76 | virtualenvs-in-project: true 77 | - name: cache deps 78 | id: cache-deps 79 | uses: actions/cache@v2 80 | with: 81 | path: .venv 82 | key: pydeps-${{ hashFiles('**/poetry.lock') }} 83 | - name: prerelease 84 | env: 85 | PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 86 | run: | 87 | poetry config pypi-token.pypi $PYPI_TOKEN 88 | poetry version prerelease 89 | poetry publish --build 90 | - name: update version 91 | uses: stefanzweifel/git-auto-commit-action@v4 92 | with: 93 | commit_message: Automatic version bump 94 | file_pattern: pyproject.toml 95 | 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .eggs 3 | dist 4 | build 5 | *.pyc 6 | *.egg-info 7 | .DS_Store 8 | settings-test.yaml 9 | .env 10 | docs/_build 11 | .password 12 | .python-version 13 | .tox 14 | Pipefile.lock 15 | *.key 16 | *.crt 17 | *.pem 18 | *.pkcs12 19 | bom.json 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Matthew Gill 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test docs 2 | test: 3 | poetry run pytest 4 | poetry run mypy 5 | 6 | docs: 7 | cd docs; poetry install && poetry run make clean html; cd - 8 | @echo "\033[95m\n\nBuild successful! View the docs homepage at docs/_build/html/index.html.\n\033[0m" 9 | 10 | gh-pages: 11 | git fetch origin gh-pages 12 | git checkout gh-pages 13 | git checkout master docs 14 | git checkout master pysnc 15 | git reset HEAD 16 | cd docs && make html && cd - 17 | cp -rf docs/_build/html/* ./ 18 | #git add -A 19 | #git commit -m 'Generated gh-pages' && git push origin gh-pages && git checkout master -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ServiceNow Python API 2 | 3 | **What:** 4 | PySnc is a python interface for the ServiceNow and the Table API. It is designed to mimic the familiar GlideRecord interface you know with pythonic support where applicable. 5 | 6 | **Why:** 7 | Spawned from the desire to interact with ServiceNow data in a familiar and consistent manner 8 | 9 | ## Install 10 | 11 | ``` 12 | pip install pysnc 13 | ``` 14 | 15 | ## Quick Start 16 | 17 | ```python 18 | from pysnc import ServiceNowClient 19 | 20 | client = ServiceNowClient('https://dev0000.service-now.com', ('integration.user', password)) 21 | 22 | gr = client.GlideRecord('sys_user') 23 | gr.add_query('user_name', 'admin') 24 | gr.query() 25 | for r in gr: 26 | print(r.sys_id) 27 | ``` 28 | 29 | ## Documentation 30 | 31 | Full documentation currently available at [https://servicenow.github.io/PySNC/](https://servicenow.github.io/PySNC/) 32 | 33 | Or build them yourself: 34 | 35 | ``` 36 | cd docs && make html 37 | ``` 38 | 39 | ## Development Notes 40 | 41 | The following functions are not (yet?) supported: 42 | 43 | * `choose_window(first_row, last_row, force_count=True)` TODO 44 | * `get_class_display_value()` 45 | * `get_record_class_name()` 46 | * `is_valid()` TODO 47 | * `is_valid_record()` 48 | * `new_record()` 49 | * `_next()` 50 | * `_query()` 51 | 52 | The following will not be implemented: 53 | 54 | * `get_attribute(field_name)` Not Applicable 55 | * `get_ED()` Not Applicable 56 | * `get_label()` Not Applicable 57 | * `get_last_error_message()` Not Applicable 58 | * `set_workflow(enable)` Not Possible 59 | * `operation()` Not Applicable 60 | * `set_abort_action()` Not Applicable 61 | * `is_valid_field()` Not Possible 62 | * `is_action_aborted()` Not Applicable 63 | 64 | # Feature Wants and TODO 65 | 66 | * GlideAggregate support (`/api/now/stats/{tableName}`) 67 | 68 | And we want to: 69 | 70 | * Improve documentation -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = poetry run python -msphinx 7 | SPHINXPROJ = pysnc 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | The API 4 | ======= 5 | 6 | .. module:: pysnc 7 | 8 | The ServiceNowClient 9 | -------------------- 10 | 11 | .. autoclass:: ServiceNowClient 12 | :inherited-members: 13 | 14 | GlideRecord 15 | ----------- 16 | 17 | .. autoclass:: GlideRecord 18 | :inherited-members: 19 | 20 | .. autoclass:: GlideElement 21 | :inherited-members: 22 | 23 | Attachment 24 | ---------- 25 | 26 | .. autoclass:: Attachment 27 | :inherited-members: 28 | 29 | APIs 30 | ---- 31 | 32 | These are 'internal' but you may as well know about them 33 | 34 | .. autoclass:: API 35 | :inherited-members: 36 | :undoc-members: 37 | 38 | .. autoclass:: TableAPI 39 | :inherited-members: 40 | :undoc-members: 41 | 42 | .. autoclass:: AttachmentAPI 43 | :inherited-members: 44 | :undoc-members: 45 | 46 | .. autoclass:: BatchAPI 47 | :inherited-members: 48 | :undoc-members: 49 | 50 | Exceptions 51 | ---------- 52 | 53 | .. automodule:: pysnc.exceptions 54 | :members: 55 | :undoc-members: 56 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pysnc documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Jun 7 10:28:30 2018. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import importlib.metadata 20 | 21 | 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.coverage', 35 | 'sphinx.ext.viewcode' 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = u'PySNC' 52 | copyright = u'2023, Matthew Gill' 53 | author = u'Matthew Gill' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = importlib.metadata.version("pysnc") 61 | # The full version, including alpha/beta/rc tags. 62 | release = importlib.metadata.version("pysnc") 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = 'en' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'alabaster' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = [] # ['_static'] 100 | 101 | # Custom sidebar templates, must be a dictionary that maps document names 102 | # to template names. 103 | # 104 | # This is required for the alabaster theme 105 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 106 | html_sidebars = { 107 | '**': [ 108 | 'about.html', 109 | 'navigation.html', 110 | 'relations.html', # needs 'show_related': True theme option to display 111 | 'searchbox.html', 112 | 'donate.html', 113 | ] 114 | } 115 | 116 | 117 | # -- Options for HTMLHelp output ------------------------------------------ 118 | 119 | # Output file base name for HTML help builder. 120 | htmlhelp_basename = 'pysncdoc' 121 | 122 | 123 | # -- Options for LaTeX output --------------------------------------------- 124 | 125 | latex_elements = { 126 | # The paper size ('letterpaper' or 'a4paper'). 127 | # 128 | # 'papersize': 'letterpaper', 129 | 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | 134 | # Additional stuff for the LaTeX preamble. 135 | # 136 | # 'preamble': '', 137 | 138 | # Latex figure (float) alignment 139 | # 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, 'pysnc.tex', u'pysnc Documentation', 148 | u'Matthew Gill', 'manual'), 149 | ] 150 | 151 | 152 | # -- Options for manual page output --------------------------------------- 153 | 154 | # One entry per manual page. List of tuples 155 | # (source start file, name, description, authors, manual section). 156 | man_pages = [ 157 | (master_doc, 'pysnc', u'pysnc Documentation', 158 | [author], 1) 159 | ] 160 | 161 | 162 | # -- Options for Texinfo output ------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | (master_doc, 'pysnc', u'pysnc Documentation', 169 | author, 'pysnc', 'One line description of project.', 170 | 'Miscellaneous'), 171 | ] 172 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pysnc documentation master file, created by 2 | sphinx-quickstart on Thu Jun 7 10:28:30 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to PySNC's documentation! 7 | ================================= 8 | 9 | Release v\ |version| (:ref:`Installation `) 10 | 11 | ------------------ 12 | 13 | PySNC was created to fill the need for a familiar interface to query data from an instance from python. The interface, modeled after `GlideRecord `_, provides developers who already know ServiceNow record queries an easy, quick, and consistent method to interact with platform data. 14 | 15 | **Quickstart:** 16 | 17 | >>> client = pysnc.ServiceNowClient('dev00000', ('admin', password)) 18 | >>> gr = client.GlideRecord('sys_user') 19 | >>> gr.add_query('user_name', 'admin') 20 | >>> gr.query() 21 | >>> for r in gr: 22 | ... print(f"{r.user_name}'s sys_id is {r.sys_id}") 23 | ... 24 | admin's sys_id is 6816f79cc0a8016401c5a33be04be441 25 | 26 | Or more traditionally: 27 | 28 | >>> while gr.next(): 29 | ... print(gr.get_value('sys_id')) 30 | ... 31 | 6816f79cc0a8016401c5a33be04be441 32 | 33 | User Guide 34 | ---------- 35 | 36 | .. toctree:: 37 | :maxdepth: 2 38 | :caption: Contents: 39 | 40 | user/install 41 | user/getting_started 42 | user/advanced 43 | user/authentication 44 | 45 | The API 46 | ----------------------------- 47 | 48 | .. toctree:: 49 | :maxdepth: 2 50 | 51 | api 52 | 53 | -------------------------------------------------------------------------------- /docs/user/advanced.rst: -------------------------------------------------------------------------------- 1 | .. _advanced: 2 | 3 | Advanced 4 | ======== 5 | 6 | Attachments 7 | ----------- 8 | 9 | The most likely way to interact with an attachment: 10 | 11 | >>> gr = client.GlideRecord('problem') 12 | >>> assert gr.get(my_sys_id), 'did not find record' 13 | >>> for attachment in gr.get_attachments(): 14 | >>> data = attachment.read().decode('utf-8') 15 | 16 | or 17 | 18 | >>> for attachment in gr.get_attachments(): 19 | >>> for line in attachment.readlines(): 20 | >>> ... 21 | 22 | Other useful attachment methods being `read()` and `writeto(...)` and `as_temp_file()`. 23 | 24 | Dot Walking 25 | ----------- 26 | 27 | Since we are using the TableAPI we miss out on useful dot-walking features that the standard GlideRecord object has, such as the `.getRefRecord()` function. To mimic this behavior we must specify the fields we which to dot walk before we query as such: 28 | 29 | >>> gr.fields = 'sys_id,opened_by,opened_by.email,opened_by.department.dept_head' 30 | >>> gr.query() 31 | >>> gr.next() 32 | True 33 | >>> gr.get_value('opened_by.email') 34 | 'example@servicenow.com' 35 | >>> gr.get_value('opened_by.department.dept_head') 36 | 'a4eac1d55b64900083193b9b3e42148d' 37 | >>> gr.get_display_value('opened_by.department.dept_head') 38 | 'Fred Luddy' 39 | 40 | But we can also do it like this: 41 | 42 | >>> print(f"Our department head is {gr.opened_by.department.dept_head}") 43 | Our department head is a4eac1d55b64900083193b9b3e42148d 44 | >>> gr.opened_by.department.dept_head 45 | GlideElement(value='a4eac1d55b64900083193b9b3e42148d', name='dept_head', display_value='Fred Luddy', changed=False) 46 | 47 | 48 | Serialization 49 | ------------- 50 | 51 | We can serialize to a python ``dict`` which can be useful for other storage mechanisms:: 52 | 53 | >>> gr = client.GlideRecord('problem') 54 | >>> gr.fields = 'sys_id,short_description,state' 55 | >>> gr.query() 56 | >>> gr.next() 57 | True 58 | >>> gr.serialize() 59 | {u'short_description': u'Windows Something', u'state': u'3', u'sys_id': u'46eaa7c9a9fe198100bbe282da0d4b7d'} 60 | 61 | The serialize method supports the ``display_value``, ``fields``, and ``fmt`` arguments:: 62 | 63 | >>> gr.serialize(display_value=True) 64 | {u'short_description': u'Windowsz', u'state': u'Pending Change', u'sys_id': u'46eaa7c9a9fe198100bbe282da0d4b7d'} 65 | >>> gr.serialize(display_value='both') 66 | {u'short_description': {u'display_value': u'Windowsz', u'value': u'Windowsz'}, u'state': {u'display_value': u'Pending Change', u'value': u'3'}, u'sys_id': {u'display_value': u'46eaa7c9a9fe198100bbe282da0d4b7d', u'value': u'46eaa7c9a9fe198100bbe282da0d4b7d'}} 67 | >>> gr.serialize(fields='sys_id') 68 | {u'sys_id': u'46eaa7c9a9fe198100bbe282da0d4b7d'} 69 | 70 | The only supported ``fmt`` is ``pandas``. 71 | 72 | Join Queries 73 | ------------ 74 | 75 | See the API documentation for additional details:: 76 | 77 | >>> gr = client.GlideRecord('sys_user') 78 | >>> join_query = gr.add_join_query('sys_user_group', join_table_field='manager') 79 | >>> join_query.add_query('active','true') 80 | >>> gr.query() 81 | 82 | Related List Queries 83 | -------------------- 84 | 85 | Allows a user to perform a query comparing a related list, which allows the simulation of LEFT OUTER JOIN and etc. 86 | See the API documentation and tests for additional details. 87 | 88 | All users with the role which has a `name` of `admin`:: 89 | 90 | >>> gr = client.GlideRecord('sys_user') 91 | >>> qc = gr.add_rl_query('sys_user_has_role', 'user', '>0', True) 92 | >>> qc.add_query('role.name', 'admin') 93 | >>> gr.query() 94 | 95 | All users without any role:: 96 | 97 | >>> gr = client.GlideRecord('sys_user') 98 | >>> qc = gr.add_rl_query('sys_user_has_role', 'user', '=0') 99 | >>> gr.query() 100 | 101 | Proxies 102 | ------- 103 | 104 | You can use HTTP proxies, either specify a URL string or dict:: 105 | 106 | >>> proxy_url = 'http://localhost:8080' 107 | >>> client = pysnc.ServiceNowClient(..., proxy=proxy_url) 108 | >>> client = pysnc.ServiceNowClient(..., proxy={'http': proxy_url, 'https': proxy_url} 109 | 110 | Password2 Fields 111 | ---------------- 112 | 113 | According to official documentation, the `value` of a Password2 field will be encrypted and the `display_value` will be unencrypted based on the requesting user's encryption context 114 | 115 | Pandas 116 | ------ 117 | 118 | Transform a query into a DataFrame:: 119 | 120 | >>> import pandas as pd 121 | >>> df = pd.DataFrame(gr.to_pandas()) 122 | 123 | 124 | Performance Concerns 125 | -------------------- 126 | 127 | 1. Why is my query so slow? 128 | 129 | The following can improve performance: 130 | 131 | * Set the :ref:`GlideRecord.fields` to the minimum number of required fields 132 | * Increase (or decrease) the default :ref:`batch_size` for GlideRecord 133 | * According to `KB0534905 `_ try disabling display values if they are not required via `gr.display_value = False` 134 | * Try setting a query :ref:`limit` if you do not need all results 135 | 136 | 2. Why am I consuming so much memory? 137 | 138 | By default, GlideRecord can be 'rewound' in that it stores all previously fetched records in memory. This can be disabled by setting :ref:`rewind` to False on GlideRecord 139 | -------------------------------------------------------------------------------- /docs/user/authentication.rst: -------------------------------------------------------------------------------- 1 | .. _authentication: 2 | 3 | Authentication 4 | ============== 5 | 6 | Basic Authentication 7 | -------------------- 8 | 9 | Standard BasicAuth, every request contains your username and password base64 encoded in the request header. The most 10 | insecure, but simplest, way to authenticate. Remember to scope the user's ACLs to the minimum if using with automations. 11 | 12 | >>> client = pysnc.ServiceNowClient('dev00000', ('admin','password')) 13 | 14 | This is not recommended for any production or machine to machine activity 15 | 16 | OAuth2 - Password Grant Flow 17 | ---------------------------- 18 | 19 | OAuth2 password flow is currently supported and recommended 20 | 21 | >>> from pysnc import ServiceNowClient 22 | >>> from pysnc.auth import ServiceNowPasswordGrantFlow 23 | >>> client_id = 'ac0dd3408c1031006907010c2cc6ef6d' # oauth_entity.client_id 24 | >>> secret = '...' # oauth_entity.client_secret 25 | >>> auth = ServiceNowPasswordGrantFlow(username, password, client_id, secret)) 26 | >>> client = ServiceNowClient(server_url, auth) 27 | 28 | This will use a users credentials to retrieve and store an OAuth2 auth and refresh token, sending your auth token with 29 | every request and dropping your user and password from memory. This will automatically update your access tokens. 30 | 31 | To configure this flow, create a new OAuth Application by selecting a new OAuth API endpoint: 32 | 33 | .. image:: images/oauth2-01.png 34 | 35 | Name it, save/insert it, and note your new client_id and client_secret. This flow is sometimes called a Legacy flow, as it 36 | is not the ideal method OAuth usage. In this flow it is tolerable to share/embed your client_secret. 37 | 38 | .. image:: images/oauth2-02.png 39 | 40 | 41 | OAuth2 - Auth Code Grant Flow 42 | ----------------------------- 43 | 44 | No instructions, currently. Typically what you would want to use if you're a client doing things on behalf of users. 45 | 46 | OAuth2 - Client Credential Grant Flow 47 | ------------------------------------- 48 | 49 | ServiceNow does not support this OAuth flow 50 | 51 | OAuth2 - JWT Bearer Grant Flow 52 | ------------------------------ 53 | 54 | Create a new `JWT Provider `_ 55 | and use it with whomever generates your JWT tokens. Once you have your JWT you may use it to request a Bearer token for 56 | standard auth: 57 | 58 | >>> auth = ServiceNowJWTAuth(client_id, client_secret, token) 59 | >>> client = ServiceNowClient(self.c.server, auth) 60 | 61 | Wherein client_id and client_secret are the `oauth_jwt` record entry values and `token` is the JWT generated by your provider. 62 | 63 | The token contains who are are logging in as - as such the PySNC library does not attempt to act as a provider. We do however 64 | have an example of how that is done in the tests. 65 | 66 | mTLS - Mutual TLS Authentication (Certificate-Based Authentication) 67 | ------------------------------------------------------------------- 68 | 69 | The most ideal form of authentication for machine to machine communication. Follow `KB0993615 `_ then: 70 | 71 | >>> client = ServiceNowClient(instance, cert=('/path/to/client.cert', '/path/to/client.key')) 72 | 73 | Requests Authentication 74 | ----------------------- 75 | 76 | Ultimately any authentication method supported by python requests (https://2.python-requests.org/en/master/user/authentication/) can be passed directly to ServiceNowClient. 77 | 78 | Should be flexible enough for any use-case. 79 | 80 | Storing Passwords 81 | ----------------- 82 | 83 | The keystore module is highly recommended. For example:: 84 | 85 | import keyring 86 | 87 | 88 | def check_keyring(instance, user): 89 | passw = keyring.get_password(instance, user) 90 | return passw 91 | 92 | def set_keyring(instance, user, passw): 93 | keyring.set_password(instance, user, passw) 94 | 95 | if options.password: 96 | passw = options.password 97 | set_keyring(options.instance, options.user, passw) 98 | else: 99 | passw = check_keyring(options.instance, options.user) 100 | if passw is None: 101 | print('No Password specified and none found in keyring') 102 | sys.exit(1) 103 | 104 | client = pysnc.ServiceNowClient(options.instance, ...) 105 | -------------------------------------------------------------------------------- /docs/user/getting_started.rst: -------------------------------------------------------------------------------- 1 | .. _getting_started: 2 | 3 | Getting Started 4 | =============== 5 | 6 | Ensure you are :ref:`installed ` and have an instance to talk to. A `PDI `_ may be good start for playing around if you do not have access to a development instance. 7 | 8 | The ServiceNow client 9 | --------------------- 10 | 11 | The ``ServiceNowClient`` is your interface to an instance. 12 | 13 | First we import:: 14 | 15 | >>> from pysnc import ServiceNowClient 16 | 17 | Then we create a client:: 18 | 19 | >>> client = ServiceNowClient('dev00000', ServiceNowPasswordGrantFlow('admin', password, client_id, client_secret)) 20 | # or more insecurely... 21 | >>> client = pysnc.ServiceNowClient('dev00000', ('admin', password)) 22 | 23 | We use the client to create a ``GlideRecord`` object:: 24 | 25 | >>> gr = client.GlideRecord('incident') 26 | 27 | Familiar? The real difference is we follow python naming conventions:: 28 | 29 | >>> gr.add_query("active", "true") 30 | >>> gr.query() 31 | 32 | Or even: 33 | 34 | >>> gr.add_active_query() 35 | 36 | This means that instead of Java/JavaScript camelCase we use python standard in PEP-8. For example ``addQuery`` has become ``add_query``. 37 | 38 | Querying a record 39 | ----------------- 40 | 41 | The query methods are designed to behave very similarly to the glide implementations:: 42 | 43 | >>> gr.add_query("value", ">", "1") 44 | 45 | Or encoded queries:: 46 | 47 | >>> gr.add_encoded_query("valuesLIKE1^active=true") 48 | 49 | We can do or conditions:: 50 | 51 | >>> o = gr.add_query('priority','1') 52 | >>> o.add_or_condition('priority','2') 53 | 54 | Followed by a query:: 55 | 56 | >>> gr.query() 57 | 58 | No HTTP requests are made until `query()` is called. 59 | 60 | Get a single record 61 | ------------------- 62 | 63 | We can do our standard get:: 64 | 65 | >>> gr.get('6816f79cc0a8016401c5a33be04be441') 66 | True 67 | 68 | Or an advanced get:: 69 | 70 | >>> gr.get('number', 'PRB123456') 71 | True 72 | 73 | Iterating a record 74 | ------------------ 75 | 76 | There are two methods to iterate a GlideRecord:: 77 | 78 | >>> while gr.next(): 79 | pass 80 | >>> for r in gr: 81 | pass 82 | 83 | When using the traditional `next()` function you will have to call `rewind()` if you wish to iterate a second time. The pythonic iteration does not require this and allows for list comprehensions and other python goodies. 84 | 85 | Limiting Results 86 | ---------------- 87 | 88 | We are using the REST api after all, and sometimes we want to limit the number of requests we make for testing or sanity checking. 89 | 90 | We can set limits on the number of results returned, the following will return up to 5 records:: 91 | 92 | >>> gr.limit = 5 93 | 94 | There is no default limit to the number of records we can query. We can also specify the fields we want to return:: 95 | 96 | >>> gr.fields = 'sys_id,short_description' 97 | 98 | Or like this:: 99 | 100 | >>> gr.fields = ['sys_id', 'short_description'] 101 | 102 | This can reduce the load on our instance and how long it takes to query results. We can also use the fields feature to dotwalk table to reduce the record queries we need: 103 | 104 | >>> gr.fields = 'sys_id,assignment_group.short_description' 105 | 106 | We can also modify the batch size, which is by default 10: 107 | 108 | >>> gr = client.GlideRecord('...', batch_size=50) 109 | >>> gr.batch_size = 60 110 | 111 | Batch size, along with fields and limits, will affect how the API and instance perform. This should be tuned against API usage and instance semaphore set configuration. This documentation does not provide said guidance, when in doubt leave it default. 112 | 113 | As a rule of thumb, query only the fields you actually want. 114 | 115 | Accessing fields 116 | ---------------- 117 | 118 | We can access any field value via dot notation or the standard :py:func:`get_value` and :py:func:`get_display_value` methods. Fields are backed by :py:class:`record.GlideElement` 119 | 120 | For example:: 121 | 122 | >>> gr = client.GlideRecord('problem') 123 | >>> gr.query() 124 | >>> gr.next() 125 | True 126 | >>> gr.state 127 | record.GlideElement(name='state', value='3', display_value='Pending Change', changed=False) 128 | >>> gr.get_value('state') 129 | '3' 130 | >>> gr.get_display_value('state') 131 | 'Pending Change' 132 | >>> gr.state == '3' 133 | True 134 | >>> f"State is {gr.state}" 135 | 'State is 3' 136 | >>> gr.state.nil() 137 | False 138 | >>> gr.state = '4' 139 | >>> gr.state 140 | record.GlideElement(name='state', value='4', display_value=None, changed=True) 141 | >>> gr.state.get_value() 142 | '4' 143 | 144 | 145 | The PySNC module cannot infer what a given columns data type may be from the REST API -- this gives some limitations on possible methods such as the `getRefRecord` 146 | 147 | Setting fields 148 | -------------- 149 | 150 | >>> gr = client.GlideRecord('incident') 151 | >>> gr.initialize() 152 | >>> gr.state = 4 153 | >>> gr.get_value('state') 154 | 4 155 | >>> gr.set_value('state', 4) 156 | 157 | Length 158 | -------------- 159 | 160 | You can use `len` or `get_row_count()` (they are the same):: 161 | 162 | >>> gr = client.GlideRecord('incident') 163 | >>> len(gr) 164 | 0 165 | >>> gr.get_row_count() 166 | 0 167 | >>> gr.query() 168 | >>> len(gr) 169 | 20 170 | >>> gr.get_row_count() 171 | 20 172 | 173 | 174 | Insert, Update, Delete 175 | ---------------------- 176 | 177 | We can insert new records (you must call `initialize()` first): 178 | 179 | >>> gr = client.GlideRecord('problem') 180 | >>> gr.initialize() 181 | >>> gr.short_description = "Example Problem" 182 | >>> gr.description = "Example Description" 183 | >>> gr.insert() 184 | 'a06252790b6693009fde8a4b33673aed' 185 | 186 | And update: 187 | 188 | >>> gr = client.GlideRecord('problem') 189 | >>> gr.get('a06252790b6693009fde8a4b33673aed') 190 | True 191 | >>> gr.short_description = "Updated Problem" 192 | >>> gr.update() 193 | 'a06252790b6693009fde8a4b33673aed' 194 | 195 | And delete: 196 | 197 | >>> gr = client.GlideRecord('problem') 198 | >>> gr.get('a06252790b6693009fde8a4b33673aed') 199 | True 200 | >>> gr.delete() 201 | True 202 | -------------------------------------------------------------------------------- /docs/user/images/oauth2-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ServiceNow/PySNC/a915f82d73f07cbf00dbdaebb90a818ecbe15cd0/docs/user/images/oauth2-01.png -------------------------------------------------------------------------------- /docs/user/images/oauth2-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ServiceNow/PySNC/a915f82d73f07cbf00dbdaebb90a818ecbe15cd0/docs/user/images/oauth2-02.png -------------------------------------------------------------------------------- /docs/user/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation of PySNC 4 | ===================== 5 | 6 | As such: :: 7 | 8 | $ pip install pysnc 9 | 10 | Or you can build and install it yourself :: 11 | 12 | $ poetry install 13 | ... 14 | $ poetry build 15 | Building pysnc (1.0.7) 16 | - Building sdist 17 | - Built pysnc-1.0.7.tar.gz 18 | - Building wheel 19 | - Built pysnc-1.0.7-py3-none-any.wh 20 | $ pip install pysnc-1.0.7-py3-none-any.wh 21 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | files = pysnc/ 4 | 5 | 6 | pretty = True -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pysnc" 3 | version = "1.1.10" 4 | description = "Python SNC (REST) API" 5 | authors = ["Matthew Gill "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/ServiceNow/PySNC" 9 | documentation = "https://servicenow.github.io/PySNC/" 10 | keywords = ["servicenow", "snc"] 11 | classifiers = [ 12 | 'Development Status :: 4 - Beta', 13 | 'Intended Audience :: Developers', 14 | 'Programming Language :: Python :: 3', 15 | 'Programming Language :: Python :: 3.7', 16 | 'Operating System :: OS Independent', 17 | ] 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.8" 21 | requests = "^2.31.0" 22 | requests-oauthlib = { version = ">=1.2.0", optional = true} 23 | certifi = "^2024.7.4" 24 | urllib3 = "^2.0.7" 25 | 26 | [tool.poetry.extras] 27 | oauth = ["requests-oauthlib"] 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | requests-oauthlib = ">=1.2.0" 31 | pytest = "^7.3.1" 32 | python-dotenv = "^1.0.0" 33 | mypy = "^1.2.0" 34 | types-requests = "^2.29.0.0" 35 | types-oauthlib = "^3.2.0.7" 36 | jake = "^3.0.0" 37 | 38 | [tool.poetry.group.docs.dependencies] 39 | sphinx = "*" 40 | 41 | [build-system] 42 | requires = ["poetry-core"] 43 | build-backend = "poetry.core.masonry.api" 44 | -------------------------------------------------------------------------------- /pysnc/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import * 2 | from .record import * 3 | from .auth import * 4 | #from .exceptions import * 5 | 6 | from .__version__ import __version__ 7 | -------------------------------------------------------------------------------- /pysnc/__version__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | __title__ = 'pysnc' 3 | __version__ = importlib.metadata.version(__title__) 4 | -------------------------------------------------------------------------------- /pysnc/attachment.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import logging 3 | from tempfile import SpooledTemporaryFile 4 | from pathlib import Path 5 | from typing import List, Optional 6 | 7 | from .record import GlideElement 8 | from .query import * 9 | from .exceptions import * 10 | 11 | 12 | class Attachment: 13 | MAX_MEM_SIZE = 0xFFFF 14 | 15 | # TODO refactor this to use a .get method 16 | def __init__(self, client, table): 17 | """ 18 | :param str table: the table we are associated with 19 | """ 20 | self.__is_iter = False 21 | self._client = client 22 | self._log = logging.getLogger(__name__) 23 | self._table = table 24 | self.__results = [] 25 | self.__current = -1 26 | self.__page = -1 27 | self.__total = None 28 | self.__limit = None 29 | self.__encoded_query = None 30 | self.__query = Query(table) 31 | # we need to default the table 32 | self.add_query('table_name', table) 33 | 34 | def _clear_query(self): 35 | self.__query = Query(self.__table) 36 | 37 | def _parameters(self): 38 | ret = dict( 39 | sysparm_query=self.__query.generate_query(encoded_query=self.__encoded_query) 40 | ) 41 | # Batch size matters! Transaction limits will exceed. 42 | # This also means we have to be pretty specific with limits 43 | limit = None 44 | if self.__limit: 45 | if self.__limit >= self.batch_size: 46 | # need to re-calc as our actual queried count will end up greater than our limit 47 | # this keeps us at our actual limit even when between batch size boundaries 48 | limit = self.__limit - self.__current - 1 49 | elif self.__limit <= self.batch_size or self.__limit > 0: 50 | limit = self.__limit 51 | if limit: 52 | ret['sysparm_limit'] = limit 53 | if self.__current == -1: 54 | ret['sysparm_offset'] = 0 55 | else: 56 | ret['sysparm_offset'] = self.__current + 1 57 | return ret 58 | 59 | def _current(self): 60 | if self.__current > -1 and self.__current < len(self.__results): 61 | return self.__results[self.__current] 62 | return None 63 | 64 | def __iter__(self): 65 | self.__is_iter = True 66 | self.__current = -1 67 | return self 68 | 69 | def __next__(self): 70 | return self.next() 71 | 72 | def next(self, _recursive=False): 73 | """ 74 | Returns the next record in the record set 75 | 76 | :return: ``True`` or ``False`` based on success 77 | """ 78 | l = len(self.__results) 79 | if l > 0 and self.__current + 1 < l: 80 | self.__current = self.__current + 1 81 | if self.__is_iter: 82 | return self 83 | return True 84 | if self.__total > 0 and \ 85 | (self.__current + 1) < self.__total and \ 86 | self.__total > len(self.__results) and \ 87 | _recursive is False: 88 | if self.__limit: 89 | if self.__current + 1 < self.__limit: 90 | self.query() 91 | return self.next(_recursive=True) 92 | else: 93 | self.query() 94 | return self.next(_recursive=True) 95 | if self.__is_iter: 96 | self.__is_iter = False 97 | raise StopIteration() 98 | return False 99 | 100 | def as_temp_file(self, chunk_size: int = 512) -> SpooledTemporaryFile: 101 | """ 102 | Return the attachment as a TempFile 103 | 104 | :param chunk_size: bytes to read in at a time from the HTTP stream 105 | :return: SpooledTemporaryFile 106 | """ 107 | assert self._current(), "Cannot read nothing, iterate the attachment" 108 | tf = SpooledTemporaryFile(max_size=1024 * 1024, mode='w+b') 109 | 110 | with self._client.attachment_api.get_file(self.sys_id) as r: 111 | for chunk in r.iter_content(chunk_size): 112 | tf.write(chunk) 113 | tf.seek(0) 114 | return tf 115 | 116 | def write_to(self, path, chunk_size=512) -> Path: 117 | """ 118 | Write the attachment to the given path - if the path is a directory the file_name will be used 119 | """ 120 | assert self._current(), "Cannot read nothing, iterate the attachment" 121 | p = Path(path) 122 | # if we specify a dir, auto set the filename 123 | if p.is_dir(): 124 | p = p / self.file_name 125 | with open(p, 'wb') as f: 126 | with self._client.attachment_api.get_file(self.sys_id) as r: 127 | for chunk in r.iter_content(chunk_size): 128 | f.write(chunk) 129 | return p 130 | 131 | def read(self) -> bytes: 132 | """ 133 | Read the entire attachment 134 | :return: b'' 135 | """ 136 | assert self._current(), "Cannot read nothing, iterate the attachment" 137 | return self._client.attachment_api.get_file(self.sys_id, stream=False).content 138 | 139 | def readlines(self, encoding='UTF-8', delimiter='\n') -> List[str]: 140 | """ 141 | Read the attachment, as text, decoding by default as UTF-8, splitting by the delimiter. 142 | :param encoding: encoding to use, defaults to UTF-8 143 | :param delimiter: what to split by, defualt \n 144 | :return: list 145 | """ 146 | return self.read().decode(encoding).split(delimiter) 147 | 148 | def query(self): 149 | """ 150 | Query the table 151 | 152 | :return: void 153 | :raise: 154 | :AuthenticationException: If we do not have rights 155 | :RequestException: If the transaction is canceled due to execution time 156 | """ 157 | response = self._client.attachment_api.list(self) 158 | try: 159 | self.__results = self.__results + response.json()['result'] 160 | self.__page = self.__page + 1 161 | self.__total = int(response.headers['X-Total-Count']) 162 | except Exception as e: 163 | if 'Transaction cancelled: maximum execution time exceeded' in response.text: 164 | raise RequestException( 165 | 'Maximum execution time exceeded. Lower batch size (< %s).' % self.__batch_size) 166 | else: 167 | traceback.print_exc() 168 | self._log.debug(response.text) 169 | raise e 170 | 171 | def _transform_result(self, result): 172 | for key, value in result.items(): 173 | result[key] = GlideElement(key, value, parent_record=self) 174 | return result 175 | 176 | def get(self, sys_id: str) -> bool: 177 | """ 178 | Get a single record, accepting two values. If one value is passed, assumed to be sys_id. If two values are 179 | passed in, the first value is the column name to be used. Can return multiple records. 180 | 181 | :param sys_id: the id of the attachment 182 | :return: ``True`` or ``False`` based on success 183 | """ 184 | try: 185 | response = self._client.attachment_api.get(sys_id) 186 | except NotFoundException: 187 | return False 188 | self.__results = [self._transform_result(response.json()['result'])] 189 | if len(self.__results) > 0: 190 | self.__current = 0 191 | self.__total = len(self.__results) 192 | return True 193 | return False 194 | 195 | def delete(self): 196 | response = self._client.attachment_api.delete(self.sys_id) 197 | code = response.status_code 198 | if code != 204: 199 | raise RequestException(response.text) 200 | 201 | def add_query(self, name, value, second_value=None) -> QueryCondition: 202 | """ 203 | Add a query to a record. For example:: 204 | 205 | add_query('active', 'true') 206 | 207 | Which will create the query ``active=true``. If we specify the second_value:: 208 | 209 | add_query('name', 'LIKE', 'test') 210 | 211 | Which will create the query ``nameLIKEtest`` 212 | 213 | 214 | :param str name: Table field name 215 | :param str value: Either the value in which ``name`` must be `=` to else an operator if ``second_value`` is specified 216 | 217 | Numbers:: 218 | 219 | * = 220 | * != 221 | * > 222 | * >= 223 | * < 224 | * <= 225 | 226 | Strings:: 227 | 228 | * = 229 | * != 230 | * IN 231 | * NOT IN 232 | * STARTSWITH 233 | * ENDSWITH 234 | * CONTAINS 235 | * DOES NOT CONTAIN 236 | * INSTANCEOF 237 | 238 | :param str second_value: optional, if specified then ``value`` is expected to be an operator 239 | """ 240 | return self.__query.add_query(name, value, second_value) 241 | 242 | def add_attachment(self, table_sys_id, file_name, file, content_type=None, encryption_context=None) -> str: 243 | r = self._client.attachment_api.upload_file(file_name, self._table, table_sys_id, file, content_type, 244 | encryption_context) 245 | # Location header contains the attachment URL 246 | return r.headers['Location'] 247 | 248 | def get_link(self) -> Optional[str]: 249 | if self._current(): 250 | return f"{self._client.instance}/api/now/v1/attachment/{self.sys_id}/file" 251 | return None 252 | 253 | def _get_value(self, item, key='value'): 254 | obj = self._current() 255 | if item in obj: 256 | o = obj[item] 257 | if isinstance(o, dict): 258 | return o[key] 259 | else: 260 | return o 261 | return None 262 | 263 | def __getattr__(self, item): 264 | # TODO: allow override for record fields which may overload our local properties by prepending _ 265 | obj = self._current() 266 | if obj: 267 | return self._get_value(item) 268 | return self.__getattribute__(item) 269 | 270 | def __contains__(self, item): 271 | obj = self._current() 272 | if obj: 273 | return item in obj 274 | return False 275 | 276 | def __len__(self): 277 | return self.__total if self.__total else 0 278 | -------------------------------------------------------------------------------- /pysnc/auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | from requests.auth import AuthBase 4 | from urllib3.util import parse_url 5 | from .exceptions import * 6 | 7 | JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer' 8 | 9 | 10 | class ServiceNowFlow: 11 | def authenticate(self, instance: str, **kwargs) -> requests.Session: 12 | raise AuthenticationException('authenticate not implemented') 13 | 14 | 15 | # note: not extending LegacyApplicationClient mostly to make oauth libs optional 16 | class ServiceNowPasswordGrantFlow(ServiceNowFlow): 17 | 18 | def __init__(self, username, password, client_id, client_secret): 19 | """ 20 | Password flow authentication using 'legacy mobile' 21 | 22 | :param username: The user name to authenticate with 23 | :param password: The user's password 24 | :param client_id: The ID of the provider 25 | :param client_secret: Secret for the given provider (client_id) 26 | """ 27 | if isinstance(username, (tuple, list)): 28 | self.__username = username[0] 29 | self.__password = username[1] 30 | else: 31 | self.__username = username 32 | self.__password = password 33 | self.client_id = client_id 34 | self.__secret = client_secret 35 | 36 | def authorization_url(self, authorization_base_url): 37 | return f"{authorization_base_url}/oauth_token.do" 38 | 39 | def authenticate(self, instance: str, **kwargs) -> requests.Session: 40 | """ 41 | Designed to be called by ServiceNowClient - internal method. 42 | """ 43 | try: 44 | from oauthlib.oauth2 import LegacyApplicationClient 45 | from requests_oauthlib import OAuth2Session # type: ignore 46 | 47 | oauth = OAuth2Session(client=LegacyApplicationClient(client_id=self.client_id), 48 | auto_refresh_url=self.authorization_url(instance), 49 | auto_refresh_kwargs=dict(client_id=self.client_id, client_secret=self.__secret)) 50 | oauth.fetch_token(token_url=self.authorization_url(instance), 51 | username=self.__username, password=self.__password, client_id=self.client_id, 52 | client_secret=self.__secret, **kwargs) 53 | self.__password = None # no longer need this. 54 | return oauth 55 | except ImportError: 56 | raise AuthenticationException('Install dependency requests-oauthlib') 57 | 58 | 59 | class ServiceNowJWTAuth(AuthBase): 60 | 61 | def __init__(self, client_id, client_secret, jwt): 62 | """ 63 | You must obtain a signed JWT from your OIDC provider, e.g. okta or auth0 or the like. 64 | We then use that JWT to issue an OAuth refresh token, that we then use to auth. 65 | """ 66 | self.client_id = client_id 67 | self.__secret = client_secret 68 | self.__jwt = jwt 69 | self.__token = None 70 | self.__expires_at = None 71 | 72 | def _get_access_token(self, request): 73 | url = parse_url(request.url) 74 | token_url = f"{url.scheme}://{url.host}/oauth_token.do" 75 | headers = { 76 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 77 | 'Authentication': f"Bearer {self.__jwt}" 78 | } 79 | data = { 80 | 'grant_type': JWT_GRANT_TYPE, 81 | 'client_id': self.client_id, 82 | 'client_secret': self.__secret 83 | } 84 | r = requests.post(token_url, headers=headers, data=data) 85 | assert r.status_code == 200, f"Failed to auth, see syslogs {r.text}" 86 | data = r.json() 87 | expires = int(time.time()+data['expires_in']) 88 | return data['access_token'], expires 89 | 90 | def __call__(self, request): 91 | if not self.__token or time.time() > self.__expires_at: 92 | self.__token, self.__expires_at = self._get_access_token(request) 93 | request.headers['Authorization'] = f"Bearer {self.__token}" 94 | return request 95 | -------------------------------------------------------------------------------- /pysnc/client.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import requests 4 | from requests.auth import AuthBase 5 | import re 6 | import logging 7 | import base64 8 | from typing import Callable, no_type_check 9 | 10 | from requests.cookies import MockRequest, MockResponse 11 | from requests.structures import CaseInsensitiveDict 12 | from requests.utils import get_encoding_from_headers 13 | from requests.adapters import HTTPAdapter, Retry 14 | 15 | from .exceptions import * 16 | from .record import GlideRecord 17 | from .attachment import Attachment 18 | from .utils import get_instance, MockHeaders 19 | from .auth import ServiceNowFlow 20 | 21 | 22 | class ServiceNowClient(object): 23 | """ 24 | ServiceNow Python Client 25 | 26 | :param str instance: The instance to connect to e.g. ``https://dev00000.service-now.com`` or ``dev000000`` 27 | :param auth: Username password combination ``(name,pass)`` or :class:`pysnc.ServiceNowOAuth2` or ``requests.sessions.Session`` or ``requests.auth.AuthBase`` object 28 | :param proxy: HTTP(s) proxy to use as a str ``'http://proxy:8080`` or dict ``{'http':'http://proxy:8080'}`` 29 | :param bool verify: Verify the SSL/TLS certificate OR the certificate to use. Useful if you're using a self-signed HTTPS proxy. 30 | :param cert: if String, path to ssl client cert file (.pem). If Tuple, (‘cert’, ‘key’) pair. 31 | """ 32 | def __init__(self, instance, auth, proxy=None, verify=None, cert=None, auto_retry=True): 33 | self._log = logging.getLogger(__name__) 34 | self.__instance = get_instance(instance) 35 | 36 | if proxy: 37 | if type(proxy) != dict: 38 | proxies = dict(http=proxy, https=proxy) 39 | else: 40 | proxies = proxy 41 | self.__proxies = proxies 42 | if verify is None: 43 | verify = True # default to verify with proxy 44 | else: 45 | self.__proxies = None 46 | 47 | 48 | if auth is not None and cert is not None: 49 | raise AuthenticationException('Cannot specify both auth and cert') 50 | elif isinstance(auth, (list, tuple)) and len(auth) == 2: 51 | self.__user = auth[0] 52 | auth = requests.auth.HTTPBasicAuth(auth[0], auth[1]) 53 | 54 | self.__session = requests.session() 55 | self.__session.auth = auth 56 | elif isinstance(auth, AuthBase): 57 | self.__session = requests.session() 58 | self.__session.auth = auth 59 | elif isinstance(auth, requests.sessions.Session): 60 | # maybe we've got an oauth token? Let this be permissive 61 | self.__session = auth 62 | elif isinstance(auth, ServiceNowFlow): 63 | self.__session = auth.authenticate(self.__instance, proxies=self.__proxies, verify=verify) 64 | elif cert is not None: 65 | self.__session.cert = cert 66 | else: 67 | raise AuthenticationException('No valid authentication method provided') 68 | 69 | if proxy: 70 | self.__session.proxies = self.__proxies 71 | 72 | if verify is not None: 73 | self.__session.verify = verify 74 | 75 | self.__session.headers.update(dict(Accept="application/json")) 76 | 77 | if auto_retry is True: 78 | # https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#module-urllib3.util.retry 79 | retry = Retry(total=4, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503]) 80 | self.__session.mount(self.__instance, HTTPAdapter(max_retries=retry)) 81 | 82 | self.table_api = TableAPI(self) 83 | self.attachment_api = AttachmentAPI(self) 84 | self.batch_api = BatchAPI(self) 85 | 86 | def GlideRecord(self, table, batch_size=100, rewindable=True) -> GlideRecord: 87 | """ 88 | Create a :class:`pysnc.GlideRecord` for a given table against the current client 89 | 90 | :param str table: The table name e.g. ``problem`` 91 | :param int batch_size: Batch size (items returned per HTTP request). Default is ``100``. 92 | :param bool rewindable: If we can rewind the record. Default is ``True``. If ``False`` then we cannot rewind 93 | the record, which means as an Iterable this object will be 'spent' after iteration. 94 | This is normally the default behavior expected for a python Iterable, but not a GlideRecord. 95 | When ``False`` less memory will be consumed, as each previous record will be collected. 96 | :return: :class:`pysnc.GlideRecord` 97 | """ 98 | return GlideRecord(self, table, batch_size, rewindable) 99 | 100 | def Attachment(self, table) -> Attachment: 101 | """ 102 | Create an Attachment object for the current client 103 | 104 | :return: :class:`pysnc.Attachment` 105 | """ 106 | return Attachment(self, table) 107 | 108 | @property 109 | def instance(self) -> str: 110 | """ 111 | The instance we're associated with. 112 | 113 | :return: Instance URI 114 | :rtype: str 115 | """ 116 | return self.__instance 117 | 118 | @property 119 | def session(self): 120 | """ 121 | :return: The requests session 122 | """ 123 | return self.__session 124 | 125 | @staticmethod 126 | def guess_is_sys_id(value) -> bool: 127 | """ 128 | Attempt to guess if this is a probable sys_id 129 | 130 | :param str value: the value to check 131 | :return: If this is probably a sys_id 132 | :rtype: bool 133 | """ 134 | return re.match(r'^[A-Za-z0-9]{32}$', value) is not None 135 | 136 | 137 | class API(object): 138 | 139 | def __init__(self, client): 140 | self._client = client 141 | 142 | @property 143 | def session(self): 144 | return self._client.session 145 | 146 | # noinspection PyMethodMayBeStatic 147 | def _set_params(self, record=None): 148 | params = {} if record is None else record._parameters() 149 | if 'sysparm_display_value' not in params: 150 | params['sysparm_display_value'] = 'all' 151 | if 'sysparm_exclude_reference_link' not in params: 152 | params['sysparm_exclude_reference_link'] = 'true' # Scratch it!, by default 153 | params['sysparm_suppress_pagination_header'] = 'true' # Required for large queries 154 | return params 155 | 156 | # noinspection PyMethodMayBeStatic 157 | def _validate_response(self, response: requests.Response) -> None: 158 | assert response is not None, f"response argument required" 159 | code = response.status_code 160 | if code >= 400: 161 | try: 162 | rjson = response.json() 163 | if code == 404: 164 | raise NotFoundException(rjson) 165 | if code == 403: 166 | raise RoleException(rjson) 167 | if code == 401: 168 | raise AuthenticationException(rjson) 169 | raise RequestException(rjson) 170 | except requests.exceptions.JSONDecodeError: 171 | raise RequestException(response.text) 172 | 173 | def _send(self, req, stream=False) -> requests.Response: 174 | # https://stackoverflow.com/a/55889308/253594 175 | # if we're oauth, we have to do magic for prepared requests 176 | 177 | if hasattr(self.session, 'token'): 178 | try: 179 | req.url, req.headers, req.data = self.session._client.add_token( 180 | req.url, http_method=req.method, body=req.data, headers=req.headers 181 | ) 182 | except Exception as e: 183 | if e.__class__.__name__ == 'TokenExpiredError': 184 | # use refresh token to get new token 185 | if self.session.auto_refresh_url: 186 | if hasattr(req, 'auth'): 187 | req.auth = None 188 | self.session.refresh_token(self.session.auto_refresh_url) 189 | else: 190 | raise e 191 | else: 192 | raise e 193 | 194 | request = self.session.prepare_request(req) 195 | # Merge environment settings into session 196 | settings = self.session.merge_environment_settings(request.url, {}, stream, None, None) 197 | r = self.session.send(request, **settings) 198 | self._validate_response(r) 199 | return r 200 | 201 | 202 | class TableAPI(API): 203 | 204 | def _target(self, table, sys_id=None) -> str: 205 | target = "{url}/api/now/table/{table}".format(url=self._client.instance, table=table) 206 | if sys_id: 207 | target = "{}/{}".format(target, sys_id) 208 | return target 209 | 210 | def list(self, record: GlideRecord) -> requests.Response: 211 | params = self._set_params(record) 212 | target_url = self._target(record.table) 213 | 214 | req = requests.Request('GET', target_url, params=params) 215 | return self._send(req) 216 | 217 | def get(self, record: GlideRecord, sys_id: str) -> requests.Response: 218 | params = self._set_params(record) 219 | # delete extra stuff 220 | if 'sysparm_offset' in params: 221 | del params['sysparm_offset'] 222 | 223 | target_url = self._target(record.table, sys_id) 224 | req = requests.Request('GET', target_url, params=params) 225 | return self._send(req) 226 | 227 | def put(self, record: GlideRecord) -> requests.Response: 228 | return self.patch(record) 229 | 230 | def patch(self, record: GlideRecord) -> requests.Response: 231 | body = record.serialize(changes_only=True) 232 | params = self._set_params() 233 | target_url = self._target(record.table, record.sys_id) 234 | req = requests.Request('PATCH', target_url, params=params, json=body) 235 | return self._send(req) 236 | 237 | def post(self, record: GlideRecord) -> requests.Response: 238 | body = record.serialize() 239 | params = self._set_params() 240 | target_url = self._target(record.table) 241 | req = requests.Request('POST', target_url, params=params, json=body) 242 | return self._send(req) 243 | 244 | def delete(self, record: GlideRecord) -> requests.Response: 245 | target_url = self._target(record.table, record.sys_id) 246 | req = requests.Request('DELETE', target_url) 247 | return self._send(req) 248 | 249 | 250 | class AttachmentAPI(API): 251 | API_VERSION = 'v1' 252 | 253 | def _target(self, sys_id=None): 254 | target = "{url}/api/now/{version}/attachment".format(url=self._client.instance, version=self.API_VERSION) 255 | if sys_id: 256 | target = "{}/{}".format(target, sys_id) 257 | return target 258 | 259 | def get(self, sys_id=None): 260 | target_url = self._target(sys_id) 261 | req = requests.Request('GET', target_url, params={}) 262 | return self._send(req) 263 | 264 | def get_file(self, sys_id, stream=True): 265 | """ 266 | This may be dangerous, as stream is true and if not fully read could leave open handles 267 | One should always ``with api.get_file(sys_id) as f:`` 268 | """ 269 | target_url = "{}/file".format(self._target(sys_id)) 270 | req = requests.Request('GET', target_url) 271 | return self._send(req, stream=stream) 272 | 273 | def list(self, attachment: Attachment): 274 | params = self._set_params(attachment) 275 | url = self._target() 276 | req = requests.Request('GET', url, params=params, headers=dict(Accept="application/json")) 277 | return self._send(req) 278 | 279 | def upload_file(self, file_name, table_name, table_sys_id, file, content_type=None, encryption_context=None): 280 | url = f"{self._target()}/file" 281 | params = {'file_name': file_name, 'table_name': table_name, 'table_sys_id': f"{table_sys_id}"} 282 | if encryption_context: 283 | params['encryption_context'] = encryption_context 284 | 285 | if not content_type: 286 | content_type = 'application/octet-stream' 287 | headers = {'Content-Type': content_type} 288 | 289 | req = requests.Request('POST', url, params=params, headers=headers, data=file) 290 | return self._send(req) 291 | 292 | def delete(self, sys_id): 293 | target_url = self._target(sys_id) 294 | req = requests.Request('DELETE', target_url) 295 | return self._send(req) 296 | 297 | 298 | class BatchAPI(API): 299 | API_VERSION = 'v1' 300 | 301 | def __init__(self, client): 302 | API.__init__(self, client) 303 | self.__requests = [] 304 | self.__stored_requests = {} 305 | self.__hooks = {} 306 | self.__request_id = 0 307 | 308 | def _batch_target(self): 309 | return "{url}/api/now/{version}/batch".format(url=self._client.instance, version=self.API_VERSION) 310 | 311 | def _table_target(self, table, sys_id=None): 312 | # note: the instance is still in here so requests behaves normally when preparing requests 313 | target = "{url}/api/now/table/{table}".format(url=self._client.instance, table=table) 314 | if sys_id: 315 | target = "{}/{}".format(target, sys_id) 316 | return target 317 | 318 | def _next_id(self): 319 | self.__request_id += 1 320 | return self.__request_id 321 | 322 | def _add_request(self, request: requests.Request, hook: Callable): 323 | prepared = request.prepare() 324 | request_id = str(id(prepared)) 325 | headers = [{'name': k, 'value': v} for (k,v) in prepared.headers.items()] 326 | relative_url = prepared.url[prepared.url.index('/', 8):] # type: ignore ## slice from the first non https:// slash 327 | 328 | now_request = { 329 | 'id': request_id, 330 | 'method': prepared.method, 331 | 'url': relative_url, 332 | 'headers': headers, 333 | #'exclude_response_headers': False 334 | } 335 | if prepared.body: 336 | now_request['body'] = base64.b64encode(prepared.body).decode() # type: ignore ## could theoretically do us dirty 337 | self.__hooks[request_id] = hook 338 | self.__stored_requests[request_id] = prepared 339 | self.__requests.append(now_request) 340 | 341 | @no_type_check 342 | def _transform_response(self, req: requests.PreparedRequest, serviced_request) -> requests.Response: 343 | # modeled after requests.adapters.HttpAdapter.build_response 344 | response = requests.Response() 345 | response.status_code = serviced_request['status_code'] 346 | headers = {k: v for (k, v) in [(e['name'], e['value']) for e in serviced_request.get("headers", [])]} 347 | response.headers = CaseInsensitiveDict(headers) 348 | response.encoding = get_encoding_from_headers(response.headers) 349 | 350 | body = base64.b64decode(serviced_request.get('body', '')) 351 | response.raw = BytesIO(body) 352 | 353 | if isinstance(req.url, bytes): 354 | response.url = req.url.decode("utf-8") 355 | else: 356 | response.url = req.url # type: ignore 357 | 358 | # cookies - kinda hack an adapter in 359 | req = MockRequest(req) 360 | res = MockResponse(MockHeaders(headers)) 361 | response.cookies.extract_cookies(res, req) 362 | 363 | response.request = req 364 | # response.connection = None 365 | 366 | return response 367 | 368 | def execute(self, attempt=0): 369 | if attempt > 2: 370 | # just give up and tell em we tried 371 | for h in self.__hooks: 372 | self.__hooks[h](None) 373 | self.__hooks = {} 374 | self.__requests = [] 375 | self.__stored_requests = {} 376 | bid = self._next_id() 377 | body = { 378 | 'batch_request_id': bid, 379 | 'rest_requests': self.__requests 380 | } 381 | r = self.session.post(self._batch_target(), json=body) 382 | self._validate_response(r) 383 | data = r.json() 384 | assert str(bid) == data['batch_request_id'], f"How did we get a response id different from {bid}" 385 | 386 | for response in data['serviced_requests']: 387 | response_id = response['id'] 388 | assert response_id in self.__hooks, f"Somehow has no hook for {response_id}" 389 | assert response_id in self.__stored_requests, f"Somehow we did not store request for {response_id}" 390 | self.__hooks[response['id']](self._transform_response(self.__stored_requests.pop(response_id), response)) 391 | del self.__hooks[response_id] 392 | self.__requests = list(filter(lambda x: x['id'] != response_id, self.__requests)) 393 | 394 | if len(data['unserviced_requests']) > 0: 395 | self.execute(attempt=attempt+1) 396 | 397 | def get(self, record: GlideRecord, sys_id: str, hook: Callable) -> None: 398 | params = self._set_params(record) 399 | if 'sysparm_offset' in params: 400 | del params['sysparm_offset'] 401 | target_url = self._table_target(record.table, sys_id) 402 | req = requests.Request('GET', target_url, params=params) 403 | self._add_request(req, hook) 404 | 405 | def put(self, record: GlideRecord, hook: Callable) -> None: 406 | self.patch(record, hook) 407 | 408 | def patch(self, record: GlideRecord, hook: Callable) -> None: 409 | body = record.serialize(changes_only=True) 410 | params = self._set_params() 411 | target_url = self._table_target(record.table, record.sys_id) 412 | req = requests.Request('PATCH', target_url, params=params, json=body) 413 | self._add_request(req, hook) 414 | 415 | def post(self, record: GlideRecord, hook: Callable): 416 | body = record.serialize() 417 | params = self._set_params() 418 | target_url = self._table_target(record.table) 419 | req = requests.Request('POST', target_url, params=params, json=body) 420 | self._add_request(req, hook) 421 | 422 | def delete(self, record: GlideRecord, hook: Callable): 423 | target_url = self._table_target(record.table, record.sys_id) 424 | req = requests.Request('DELETE', target_url) 425 | self._add_request(req, hook) 426 | 427 | def list(self, record: GlideRecord, hook: Callable): 428 | params = self._set_params(record) 429 | target_url = self._table_target(record.table) 430 | 431 | req = requests.Request('GET', target_url, params=params) 432 | self._add_request(req, hook) 433 | -------------------------------------------------------------------------------- /pysnc/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthenticationException(Exception): 2 | pass 3 | 4 | 5 | class EvaluationException(Exception): 6 | pass 7 | 8 | 9 | class AclQueryException(Exception): 10 | pass 11 | 12 | 13 | class RoleException(Exception): 14 | pass 15 | 16 | 17 | class RestException(Exception): 18 | def __init__(self, message, status_code=None): 19 | '''if isinstance(message, dict) and 'error' in message: 20 | message = message['error']['message'] 21 | self.detail = message['error']['detail'] 22 | self.status = message['error']['status']''' 23 | super(RestException, self).__init__(self, "%s - %s" % (status_code, message)) 24 | self.status_code = status_code 25 | 26 | 27 | class InsertException(RestException): 28 | pass 29 | 30 | 31 | class UpdateException(RestException): 32 | pass 33 | 34 | 35 | class DeleteException(RestException): 36 | pass 37 | 38 | 39 | class NotFoundException(Exception): 40 | pass 41 | 42 | 43 | class RequestException(Exception): 44 | pass 45 | 46 | 47 | class InstanceException(Exception): 48 | pass 49 | 50 | 51 | class UploadException(Exception): 52 | pass 53 | 54 | 55 | class NoRecordException(Exception): 56 | pass 57 | -------------------------------------------------------------------------------- /pysnc/query.py: -------------------------------------------------------------------------------- 1 | from warnings import warn 2 | 3 | 4 | class BaseCondition(object): 5 | def __init__(self, name, operator, value=None): 6 | op = operator if value else '=' 7 | true_value = value if value else operator 8 | self._name = name 9 | self._operator = op 10 | self._value = true_value 11 | 12 | def generate(self) -> str: 13 | raise Exception("Condition not implemented") 14 | 15 | 16 | class OrCondition(BaseCondition): 17 | 18 | def __init__(self, name, operator, value=None): 19 | super(self.__class__, self).__init__(name, operator, value) 20 | 21 | def generate(self) -> str: 22 | return "OR{}{}{}".format(self._name, self._operator, self._value) 23 | 24 | 25 | class QueryCondition(BaseCondition): 26 | 27 | def __init__(self, name, operator, value=None): 28 | super(self.__class__, self).__init__(name, operator, value) 29 | self.__sub_query = [] 30 | 31 | def add_or_condition(self, name, operator, value=None) -> OrCondition: 32 | sub_query = OrCondition(name, operator, value) 33 | self.__sub_query.append(sub_query) 34 | return sub_query 35 | 36 | def generate(self) -> str: 37 | query = "{}{}{}".format(self._name, self._operator, self._value) 38 | for sub_query in self.__sub_query: 39 | query = '^'.join((query, sub_query.generate())) 40 | return query 41 | 42 | 43 | class Query(object): 44 | def __init__(self, table=None): 45 | self._table = table 46 | self.__sub_query = [] 47 | self.__conditions = [] 48 | 49 | def add_active_query(self) -> QueryCondition: 50 | return self.add_query('active', 'true') 51 | 52 | def add_query(self, name, operator, value=None) -> QueryCondition: 53 | qc = QueryCondition(name, operator, value) 54 | self._add_query_condition(qc) 55 | return qc 56 | 57 | def add_join_query(self, join_table, primary_field=None, join_table_field=None) -> 'JoinQuery': 58 | assert self._table != None, "Cannot execute join query as Query object was not instantiated with a table name" 59 | join_query = JoinQuery(self._table, join_table, primary_field, join_table_field) 60 | self.__sub_query.append(join_query) 61 | return join_query 62 | 63 | def add_rl_query(self, related_table, related_field, operator_condition, stop_at_relationship): 64 | rl_query = RLQuery(self._table, related_table, related_field, operator_condition, stop_at_relationship) 65 | self.__sub_query.append(rl_query) 66 | return rl_query 67 | 68 | def _add_query_condition(self, qc): 69 | assert isinstance(qc, QueryCondition) 70 | self.__conditions.append(qc) 71 | 72 | def add_null_query(self, field) -> QueryCondition: 73 | return self.add_query(field, '', 'ISEMPTY') 74 | 75 | def add_not_null_query(self, field) -> QueryCondition: 76 | return self.add_query(field, '', 'ISNOTEMPTY') 77 | 78 | def generate_query(self, encoded_query=None, order_by=None) -> str: 79 | query = '^'.join([c.generate() for c in self.__conditions]) 80 | # Then sub queries 81 | for sub_query in self.__sub_query: 82 | if query == '': 83 | return sub_query.generate_query() 84 | query = '^'.join((query, sub_query.generate_query())) 85 | if encoded_query: 86 | query = '^'.join(filter(None, (query, encoded_query))) 87 | if order_by: 88 | query = '^'.join((query, order_by)) 89 | # dont start with ^ 90 | if query.startswith('^'): 91 | query = query[1:] 92 | return query 93 | 94 | 95 | class JoinQuery(Query): 96 | def __init__(self, table, join_table, primary_field=None, join_table_field=None): 97 | super(self.__class__, self).__init__(table) 98 | self._join_table = join_table 99 | self._primary_field = primary_field 100 | self._join_table_field = join_table_field 101 | 102 | def generate_query(self, encoded_query=None, order_by=None) -> str: 103 | query = super(self.__class__, self).generate_query(encoded_query, order_by) 104 | primary = self._primary_field if self._primary_field else "sys_id" 105 | secondary = self._join_table_field if self._join_table_field else "sys_id" 106 | res = "JOIN{table}.{primary}={j_table}.{secondary}".format( 107 | table=self._table, 108 | primary=primary, 109 | j_table=self._join_table, 110 | secondary=secondary 111 | ) 112 | # The `!` is required even if empty 113 | res = "{}!{}".format(res, query) 114 | return res 115 | 116 | 117 | class RLQuery(Query): 118 | 119 | def __init__(self, table, related_table, related_field, operator_condition, stop_at_relationship): 120 | super(self.__class__, self).__init__(table) 121 | self._related_table = related_table 122 | self._related_field = related_field 123 | self.operator_condition = operator_condition 124 | self.stop_at_relationship = stop_at_relationship 125 | 126 | def generate_query(self, encoded_query=None, order_by=None) -> str: 127 | query = super(self.__class__, self).generate_query(encoded_query, order_by) 128 | identifier = "{}.{}".format(self._related_table, self._related_field) 129 | stop_condition = ",m2m" if self.stop_at_relationship else "" 130 | query = "^{}".format(query) if query else "" 131 | return "RLQUERY{},{}{}{}^ENDRLQUERY".format(identifier, self.operator_condition, stop_condition, query) 132 | -------------------------------------------------------------------------------- /pysnc/record.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import copy 3 | import traceback 4 | from requests import Request 5 | from collections import OrderedDict 6 | from datetime import datetime, timezone 7 | from typing import Any, Union, List, Optional, TYPE_CHECKING 8 | 9 | from .query import * 10 | from .exceptions import * 11 | 12 | TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S%z" 13 | 14 | if TYPE_CHECKING: # for mypy 15 | from .client import ServiceNowClient 16 | from .attachment import Attachment 17 | 18 | 19 | class GlideElement(str): 20 | """ 21 | Object backing the value/display values of a given record entry. 22 | """ 23 | 24 | def __new__(cls, name, value, *args, **kwargs): 25 | return super(GlideElement, cls).__new__(cls, value) 26 | 27 | def __init__(self, name: str, value=None, display_value=None, parent_record=None, link=None): 28 | self._name = name 29 | self._value = None 30 | self._display_value = None 31 | self._changed = False 32 | self._link = None 33 | if isinstance(value, dict): 34 | if 'value' in value: 35 | self._value = value['value'] 36 | # only bother to set display value if it's different 37 | if 'display_value' in value and self._value != value['display_value']: 38 | self._display_value = value['display_value'] 39 | if 'link' in value: 40 | self._link = value['link'] 41 | else: 42 | self._value = value 43 | if display_value: 44 | self._display_value = display_value 45 | 46 | self._parent_record = parent_record 47 | 48 | def get_name(self) -> str: 49 | """ 50 | get the name of the field 51 | """ 52 | return self._name 53 | 54 | def get_value(self) -> Any: 55 | """ 56 | get the value of the field 57 | """ 58 | if self._value is not None: 59 | return self._value 60 | return self._display_value # if we are display only 61 | 62 | def get_display_value(self) -> Any: 63 | """ 64 | get the display value of the field, if it has one, else just the value 65 | """ 66 | if self._display_value: 67 | return self._display_value 68 | return self._value 69 | 70 | def get_link(self) -> Any: 71 | """ 72 | get the link of a field, if it has one, else None 73 | """ 74 | return self._link 75 | 76 | def set_value(self, value): 77 | """ 78 | set the value for the field. Will also set the display_value to `None` 79 | """ 80 | if isinstance(value, GlideElement): 81 | value = value.get_value() 82 | 83 | if self._value != value: 84 | self._changed = True 85 | self._value = value 86 | self._display_value = None 87 | 88 | def set_display_value(self, value: Any): 89 | """ 90 | set the display value for the field -- generally speaking does not have any affect upstream (to the server) 91 | """ 92 | if isinstance(value, GlideElement): 93 | value = value.get_display_value() 94 | if self._display_value != value: 95 | self._changed = True 96 | self._display_value = value 97 | 98 | def set_link(self, link: Any): 99 | """ 100 | set the reference link for the field -- generally speaking does not have any affect upstream (to the server) 101 | """ 102 | if isinstance(link, GlideElement): 103 | link = link.get_link() 104 | if self._link != link: 105 | self._changed = True 106 | self._link = link 107 | 108 | def changes(self) -> bool: 109 | """ 110 | :return: if we have changed this value 111 | :rtype: bool 112 | """ 113 | return self._changed 114 | 115 | def nil(self) -> bool: 116 | """ 117 | returns True if the value is None or zero length 118 | 119 | :return: if this value is anything 120 | :rtype: bool 121 | """ 122 | return not self._value or len(self._value) == 0 123 | 124 | def serialize(self) -> dict: 125 | """ 126 | Returns a dict with the `value`,`display_value`, `link` keys 127 | """ 128 | return ( 129 | { 130 | 'value': self.get_value(), 131 | 'display_value': self.get_display_value() 132 | } 133 | if self.get_link() is None 134 | else { 135 | 'value': self.get_value(), 136 | 'display_value': self.get_display_value(), 137 | 'link': self.get_link() 138 | } 139 | ) 140 | 141 | def date_numeric_value(self) -> int: 142 | """ 143 | Returns the number of milliseconds since January 1, 1970, 00:00:00 GMT for a duration field 144 | """ 145 | return int(self.date_value().timestamp() * 1000) 146 | 147 | def date_value(self) -> datetime: 148 | """ 149 | Returns the current as a UTC datetime or throws if it cannot 150 | """ 151 | # see also https://stackoverflow.com/a/53291299 152 | # note: all values are UTC, display values are by user TZ 153 | value_with_tz = f"{self.get_value()}+0000" 154 | return datetime.strptime(value_with_tz, TIMESTAMP_FORMAT) 155 | 156 | def set_date_numeric_value(self, ms: int) -> None: 157 | """ 158 | Sets the value of a date/time element to the specified number of milliseconds since January 1, 1970 00:00:00 GMT. 159 | 160 | When called, setDateNumericValue() automatically creates the necessary GlideDateTime/GlideDate/GlideDuration object, and then sets the element to the specified value. 161 | """ 162 | dt = datetime.fromtimestamp(ms/1000.0, tz=timezone.utc) 163 | self.set_value(dt.strftime(TIMESTAMP_FORMAT)[:-5]) # note: strips UTC from the end 164 | 165 | def __str__(self): 166 | #if self._display_value and self._value != self._display_value: 167 | # return dict(value=self._value, display_value=self._display_value) 168 | return str(self.get_value()) 169 | 170 | def __repr__(self): 171 | return f"GlideElement({self._value!r})" 172 | 173 | def __bool__(self): 174 | # help with the truthiness of true/false fields 175 | # theoretically could have a false case if we're a string with the value false since we dont know our types 176 | if self.get_value() == 'false': 177 | return False 178 | return bool(self.get_value()) 179 | 180 | def __magic(self, attr, arg=None): 181 | #print(f"__magic(self, {attr}, {arg}") 182 | val = arg.get_value() if isinstance(arg, GlideElement) else arg 183 | f = getattr(self.get_value(), attr) 184 | return f(val) if val is not None else f() 185 | 186 | def __eq__(self, other): 187 | return self.__magic('__eq__', other) 188 | 189 | def __ne__(self, other): 190 | return self.__magic('__ne__', other) 191 | 192 | def __len__(self): 193 | return self.__magic('__len__') 194 | 195 | def __length_hint__(self): 196 | return self.__magic('__length_hint__') 197 | 198 | def __iter__(self): 199 | # unfortunately i don't think we'll ever be smart enough to auto-support List columns 200 | return self.__magic('__iter__') 201 | 202 | def __next__(self): 203 | return self.__magic('__next__') 204 | 205 | 206 | ## Note: more complicated type operations than this should probably just be done with the get_value() directly 207 | def __add__(self, other): 208 | return self.__magic('__add__', other) 209 | 210 | def __sub__(self, other): 211 | return self.__magic('__sub__', other) 212 | 213 | def __gt__(self, other): 214 | return self.__magic('__gt__', other) 215 | 216 | def __lt__(self, other): 217 | return self.__magic('__lt__', other) 218 | 219 | def __le__(self, other): 220 | return self.__magic('__le__', other) 221 | 222 | def __ge__(self, other): 223 | return self.__magic('__ge__', other) 224 | 225 | def __contains__(self, other): 226 | return self.__magic('__contains__', other) 227 | 228 | def __getitem__(self, index): 229 | return self.__magic('__getitem__', index) 230 | 231 | def __hash__(self): 232 | return self.__magic('__hash__') 233 | 234 | def __int__(self): 235 | return int(self.get_value()) 236 | 237 | def __float__(self): 238 | return float(self.get_value()) 239 | 240 | def __complex__(self): 241 | return complex(self.get_value()) 242 | 243 | def __getattr__(self, item): 244 | if item in GlideElement.__class__.__dict__: 245 | return self.__getattribute__(item) 246 | 247 | if self._parent_record: 248 | if tv := self._parent_record.get_element(f"{self._name}.{item}"): 249 | return tv 250 | 251 | if hasattr(self.get_value(), item): 252 | return getattr(self.get_value(), item) 253 | 254 | raise AttributeError(f"{type(self.get_value())} has no attribute '{item}' nor GlideElement '{self._name}.{item}' -- did you mean to add this to the GlideRecord fields?") 255 | 256 | def __deepcopy__(self, memo): 257 | """ 258 | ultimately for copy.deepcopy and the use of .pop_record(), avoids recusion doing it this way 259 | """ 260 | ne = GlideElement(self.get_name(), self.get_value()) 261 | if self._display_value: 262 | ne.set_display_value(self._display_value) 263 | return ne 264 | 265 | 266 | class GlideRecord(object): 267 | """ 268 | The GlideRecord object. Normally instantiated via convenience method :func:`pysnc.ServiceNowClient.GlideRecord`. 269 | This object allows us to interact with a specific table via the table rest api. 270 | 271 | :param ServiceNowClient client: We need to know which instance we're connecting to 272 | :param str table: The table are we going to access 273 | :param int batch_size: Batch size (items returned per HTTP request). Default is ``500``. 274 | :param bool rewindable: If we can rewind the record. Default is ``True``. If ``False`` then we cannot rewind 275 | the record, which means as an Iterable this object will be 'spent' after iteration. 276 | This is normally the default behavior expected for a python Iterable, but not a GlideRecord. 277 | When ``False`` less memory will be consumed, as each previous record will be collected. 278 | """ 279 | def __init__(self, client: 'ServiceNowClient', table: str, batch_size: int=500, rewindable: bool=True): 280 | self._log = logging.getLogger(__name__) 281 | self._client = client 282 | self.__table: str = table 283 | self.__is_iter: bool = False 284 | self.__batch_size: int = batch_size 285 | self.__query: Query = Query(table) 286 | self.__encoded_query: Optional[str] = None 287 | self.__results: list = [] 288 | self.__current: int = -1 289 | self.__field_limits: Optional[List[str]] = None 290 | self.__view: Optional[str] = None 291 | self.__total: Optional[int] = None 292 | self.__limit: Optional[int] = None 293 | self.__page: int = -1 294 | self.__order: str = "ORDERBYsys_id" # we *need* a default order in the event we page, see issue#96 295 | self.__is_new_record: bool = False 296 | self.__display_value: Union[bool, str] = 'all' 297 | self.__exclude_reference_link: bool = True 298 | self.__rewindable = rewindable 299 | 300 | def _clear_query(self): 301 | self.__query = Query(self.__table) 302 | 303 | def _parameters(self): 304 | ret = dict( 305 | sysparm_query=self.__query.generate_query(encoded_query=self.__encoded_query, order_by=self.__order) 306 | ) 307 | if self.__field_limits and len(self.__field_limits) > 0: 308 | c = self.__field_limits 309 | if 'sys_id' not in self.__field_limits: 310 | c.insert(0, 'sys_id') 311 | 312 | ret['sysparm_fields'] = ','.join(c) 313 | if self.__view: 314 | ret['sysparm_view'] = self.__view 315 | 316 | ret['sysparm_display_value'] = str(self.display_value).lower() 317 | ret['sysparm_exclude_reference_link'] = str(self.exclude_reference_link).lower() 318 | # Batch size matters! Transaction limits will exceed. 319 | # This also means we have to be pretty specific with limits 320 | limit = None 321 | if self.__limit: 322 | if self.__limit >= self.batch_size: 323 | # need to re-calc as our actual queried count will end up greater than our limit 324 | # this keeps us at our actual limit even when between batch size boundaries 325 | if (self.__current + self.batch_size) > self.__limit: 326 | limit = self.__limit - self.__current - 1 327 | elif self.__limit <= self.batch_size or self.__limit > 0: 328 | # limit is less than batch, nothing special to do 329 | limit = self.__limit 330 | if limit is None and self.batch_size: 331 | limit = self.batch_size 332 | if limit: 333 | ret['sysparm_limit'] = limit 334 | if self.__current == -1: 335 | ret['sysparm_offset'] = 0 336 | else: 337 | ret['sysparm_offset'] = self.__current + 1 338 | return ret 339 | 340 | def _current(self): 341 | if self.__current > -1 and self.__current < len(self.__results): 342 | return self.__results[self.__current] 343 | return None 344 | 345 | @property 346 | def table(self) -> str: 347 | """ 348 | :return: The table we are operating on 349 | """ 350 | return self.__table 351 | 352 | def __len__(self): 353 | return self.get_row_count() 354 | 355 | def get_row_count(self) -> int: 356 | """ 357 | Glide compatable method. 358 | 359 | :return: the total 360 | """ 361 | return self.__total if self.__total is not None else 0 362 | 363 | @property 364 | def fields(self) -> Union[List[str], None]: 365 | """ 366 | :return: Fields in which this record will query OR has queried 367 | """ 368 | if self.__field_limits: 369 | return self.__field_limits 370 | c = self._current() 371 | if c: 372 | return list(c.keys()) 373 | else: 374 | if len(self.__results) > 0: 375 | return list(self.__results[0].keys()) 376 | return None 377 | 378 | @fields.setter 379 | def fields(self, fields: Union[str, List[str]]): 380 | """ 381 | Set the fields to query, in CSV string format or as a list 382 | """ 383 | if isinstance(fields, str): 384 | fields = fields.split(',') 385 | self.__field_limits = fields 386 | 387 | @property 388 | def view(self): 389 | """ 390 | :return: The current view 391 | """ 392 | return self.__view 393 | 394 | @view.setter 395 | def view(self, view): 396 | self.__view = view 397 | 398 | @property 399 | def limit(self) -> Optional[int]: 400 | """ 401 | :return: Query number limit 402 | """ 403 | return self.__limit 404 | 405 | @limit.setter 406 | def limit(self, count: int): 407 | self.__limit = count 408 | 409 | @property 410 | def batch_size(self) -> int: 411 | """ 412 | :return: The number of records to query in a single HTTP GET 413 | """ 414 | return self.__batch_size 415 | 416 | @batch_size.setter 417 | def batch_size(self, size: int): 418 | if self.limit: 419 | assert size < self.limit 420 | self.__batch_size = size 421 | 422 | @property 423 | def location(self) -> int: 424 | """ 425 | Current location within the iteration 426 | :return: location is -1 if iteration has not started 427 | :rtype: int 428 | """ 429 | return self.__current 430 | 431 | @location.setter 432 | def location(self, location: int): 433 | """ 434 | Set the current location 435 | 436 | :param location: the location to be at 437 | """ 438 | assert self.__total is not None, 'no location to be had when we have no query' 439 | assert -1 <= location < self.__total 440 | self.__current = location 441 | 442 | @property 443 | def display_value(self): 444 | return self.__display_value 445 | 446 | @display_value.setter 447 | def display_value(self, display_value): 448 | """ 449 | True: Returns the display values for all fields. 450 | False: Returns the actual values from the database. 451 | all: Returns both actual and display values. 452 | """ 453 | assert display_value in [True, False, 'all'] 454 | self.__display_value = display_value 455 | 456 | @property 457 | def exclude_reference_link(self): 458 | return self.__exclude_reference_link 459 | 460 | @exclude_reference_link.setter 461 | def exclude_reference_link(self, exclude_reference_link): 462 | """ 463 | True: Exclude Table API links for reference fields. 464 | False: Include Table API links for reference fields. 465 | """ 466 | assert exclude_reference_link in [True, False] 467 | self.__exclude_reference_link = exclude_reference_link 468 | 469 | def order_by(self, column: str): 470 | """ 471 | Set the order in ascending 472 | 473 | :param column: Column to sort by 474 | """ 475 | if column: 476 | self.__order = "ORDERBY%s" % column 477 | else: 478 | self.__order = "ORDERBYsys_id" 479 | 480 | def order_by_desc(self, column: str): 481 | """ 482 | Set the order in decending 483 | 484 | :param column: Column to sort by 485 | """ 486 | if column: 487 | self.__order = "ORDERBYDESC%s" % column 488 | else: 489 | self.__order = 'ORDERBYDESCsys_id' 490 | 491 | def pop_record(self) -> 'GlideRecord': 492 | """ 493 | Pop the current record into a new :class:`GlideRecord` object - equivalent to a clone of a singular record 494 | FIXME: this, by the name, should be a destructive operation, but it is not. 495 | 496 | :return: Give us a new :class:`GlideRecord` containing only the current record 497 | """ 498 | gr = GlideRecord(self._client, self.__table) 499 | c = self.__results[self.__current] 500 | gr.__results = [copy.deepcopy(c)] 501 | gr.__current = 0 502 | gr.__total = 1 503 | return gr 504 | 505 | def initialize(self): 506 | """ 507 | Must be called for records to initialize data frame. Will not be able to set values otherwise. 508 | """ 509 | self.__results = [{}] 510 | self.__current = 0 511 | self.__total = 1 512 | self.__is_new_record = True 513 | 514 | def is_new_record(self) -> bool: 515 | """ 516 | Is this a new record? 517 | :return: ``True`` or ``False`` 518 | """ 519 | return len(self.__results) == 1 and self.__is_new_record 520 | 521 | def set_new_guid_value(self, value): 522 | """ 523 | This does make an assumption the guid is a sys_id, if it is not, set the value directly. 524 | 525 | :param value: A 32 byte string that is the value 526 | """ 527 | value = str(value) 528 | assert len(value) == 32, "GUID must be a 32 byte string" 529 | self.set_value('sys_id', value) 530 | 531 | def rewind(self): 532 | """ 533 | Rewinds the record (iterable) so it may be iterated upon again. Only possible when the record is rewindable. 534 | """ 535 | if not self._is_rewindable(): 536 | raise Exception('Cannot rewind a non-rewindable record') 537 | self.__current = -1 538 | 539 | def changes(self) -> bool: 540 | """ 541 | Determines weather any of the fields in the record have changed 542 | """ 543 | obj = self._current() 544 | if obj: 545 | has_changed = False 546 | for key, value in obj.items(): 547 | has_changed |= value.changes() 548 | return has_changed 549 | return False 550 | 551 | 552 | def query(self, query=None): 553 | """ 554 | Query the table - executes a GET 555 | 556 | :raise: 557 | :AuthenticationException: If we do not have rights 558 | :RequestException: If the transaction is canceled due to execution time 559 | """ 560 | if not self._is_rewindable() and self.__current > 0: 561 | raise RuntimeError(f"huh {self._is_rewindable} and {self.__current}") 562 | # raise RuntimeError('Cannot re-query a non-rewindable record that has been iterated upon') 563 | self._do_query(query) 564 | 565 | def _do_query(self, query=None): 566 | stored = self.__query 567 | if query: 568 | assert isinstance(query, Query), 'cannot query with a non query object' 569 | self.__query = query 570 | try: 571 | short_len = len('&'.join([ f"{x}={y}" for (x,y) in self._parameters().items() ])) 572 | if short_len > 10000: # just the approx limit, but a few thousand below (i hope/think) 573 | 574 | def on_resp(r): 575 | nonlocal response 576 | response = r 577 | self._client.batch_api.list(self, on_resp) 578 | self._client.batch_api.execute() 579 | else: 580 | response = self._client.table_api.list(self) 581 | finally: 582 | self.__query = stored 583 | code = response.status_code 584 | if code == 200: 585 | try: 586 | for result in response.json()['result']: 587 | self.__results.append(self._transform_result(result)) 588 | self.__page = self.__page + 1 589 | self.__total = int(response.headers['X-Total-Count']) 590 | # cannot call query before this... 591 | except Exception as e: 592 | if 'Transaction cancelled: maximum execution time exceeded' in response.text: 593 | raise RequestException('Maximum execution time exceeded. Lower batch size (< %s).' % self.__batch_size) 594 | else: 595 | traceback.print_exc() 596 | self._log.debug(response.text) 597 | raise e 598 | 599 | elif code == 401: 600 | raise AuthenticationException(response.json()['error']) 601 | 602 | def get(self, name, value=None) -> bool: 603 | """ 604 | Get a single record, accepting two values. If one value is passed, assumed to be sys_id. If two values are 605 | passed in, the first value is the column name to be used. Can return multiple records. 606 | 607 | :param value: the ``sys_id`` or the field to query 608 | :param value2: the field value 609 | :return: ``True`` or ``False`` based on success 610 | """ 611 | if value is None: 612 | try: 613 | response = self._client.table_api.get(self, name) 614 | except NotFoundException: 615 | return False 616 | self.__results = [self._transform_result(response.json()['result'])] 617 | if len(self.__results) > 0: 618 | self.__current = 0 619 | self.__total = len(self.__results) 620 | return True 621 | return False 622 | else: 623 | self.add_query(name, value) 624 | self._do_query() 625 | return self.next() 626 | 627 | def insert(self) -> Optional[GlideElement]: 628 | """ 629 | Insert a new record. 630 | 631 | :return: The ``sys_id`` of the record created or ``None`` 632 | :raise: 633 | :AuthenticationException: If we do not have rights 634 | :InsertException: For any other failure reason 635 | """ 636 | response = self._client.table_api.post(self) 637 | code = response.status_code 638 | if code == 201: 639 | self.__results = [self._transform_result(response.json()['result'])] 640 | if len(self.__results) > 0: 641 | self.__current = 0 642 | self.__total = len(self.__results) 643 | return self.sys_id 644 | return None 645 | elif code == 401: 646 | raise AuthenticationException(response.json()['error']) 647 | else: 648 | rjson = response.json() 649 | raise InsertException(rjson['error'] if 'error' in rjson else f"{code} response on insert -- expected 201", status_code=code) 650 | 651 | def update(self) -> Optional[GlideElement]: 652 | """ 653 | Update the current record. 654 | 655 | :return: The ``sys_id`` on success or ``None`` 656 | :raise: 657 | :AuthenticationException: If we do not have rights 658 | :UpdateException: For any other failure reason 659 | """ 660 | response = self._client.table_api.put(self) 661 | code = response.status_code 662 | if code == 200: 663 | # splice in the response, mostly important with brs/calc'd fields 664 | result = self._transform_result(response.json()['result']) 665 | if len(self.__results) > 0: # when would this NOT be true...? 666 | self.__results[self.__current] = result 667 | return self.sys_id 668 | return None 669 | elif code == 401: 670 | raise AuthenticationException(response.json()['error']) 671 | else: 672 | raise UpdateException(response.json(), status_code=code) 673 | 674 | def delete(self) -> bool: 675 | """ 676 | Delete the current record 677 | 678 | :return: ``True`` on success 679 | :raise: 680 | :AuthenticationException: If we do not have rights 681 | :DeleteException: For any other failure reason 682 | """ 683 | response = self._client.table_api.delete(self) 684 | code = response.status_code 685 | if code == 204: 686 | return True 687 | elif code == 401: 688 | raise AuthenticationException(response.json()['error']) 689 | else: 690 | raise DeleteException(response.json(), status_code=code) 691 | 692 | def delete_multiple(self) -> bool: 693 | """ 694 | Deletes the current query, funny enough this is the same as iterating and deleting each record since we're 695 | using the REST api. 696 | 697 | :return: ``True`` on success 698 | :raise: 699 | :AuthenticationException: If we do not have rights 700 | :DeleteException: For any other failure reason 701 | """ 702 | if self.__total is None: 703 | if not self.__field_limits: 704 | self.fields = 'sys_id' # type: ignore ## all we need... 705 | self._do_query() 706 | 707 | allRecordsWereDeleted = True 708 | def handle(response): 709 | nonlocal allRecordsWereDeleted 710 | if response is None or response.status_code != 204: 711 | allRecordsWereDeleted = False 712 | 713 | for e in self: 714 | self._client.batch_api.delete(e, handle) 715 | self._client.batch_api.execute() 716 | return allRecordsWereDeleted 717 | 718 | def update_multiple(self, custom_handler=None) -> bool: 719 | """ 720 | Updates multiple records at once. A ``custom_handler`` of the form ``def handle(response: requests.Response | None)`` can be passed in, 721 | which may be useful if you wish to handle errors in a specific way. Note that if a custom_handler is used this 722 | method will always return ``True`` 723 | 724 | 725 | :return: ``True`` on success, ``False`` if any records failed. If custom_handler is specified, always returns ``True`` 726 | """ 727 | updated = True 728 | def handle(response): 729 | nonlocal updated 730 | if response is None or response.status_code != 200: 731 | updated = False 732 | 733 | for e in self: 734 | if e.changes(): 735 | self._client.batch_api.put(e, custom_handler if custom_handler else handle) 736 | 737 | self._client.batch_api.execute() 738 | return updated 739 | 740 | def _get_value(self, item, key='value'): 741 | obj = self._current() 742 | if obj is None: 743 | raise NoRecordException('cannot get a value from nothing, did you forget to call next() or initialize()?') 744 | if item in obj: 745 | o = obj[item] 746 | if key == 'display_value': 747 | return o.get_display_value() 748 | if key == 'link': 749 | return o.get_link() 750 | return o.get_value() 751 | return None 752 | 753 | def get_value(self, field) -> Any: 754 | """ 755 | Return the value field for the given field 756 | 757 | :param str field: The field 758 | :return: The field value or ``None`` 759 | """ 760 | return self._get_value(field, 'value') 761 | 762 | def get_display_value(self, field) -> Any: 763 | """ 764 | Return the display value for the given field 765 | 766 | :param str field: The field, required 767 | :return: The field value or ``None`` 768 | """ 769 | assert field, 'cannot get the display value for the entire record, as the API does not tell us what that is' 770 | return self._get_value(field, 'display_value') 771 | 772 | def get_element(self, field) -> GlideElement: 773 | """ 774 | Return the backing GlideElement for the given field. This is the only method to directly access this element.gr2.serialize() 775 | 776 | :param str field: The Field 777 | :return: The GlideElement class or ``None`` 778 | """ 779 | c = self._current() 780 | if c is None: 781 | raise NoRecordException('cannot get a value from nothing, did you forget to call next() or initialize()?') 782 | return self._current()[field] if field in c else None 783 | 784 | def set_value(self, field, value): 785 | """ 786 | Set the value for a field. 787 | 788 | :param str field: The field 789 | :param value: The Value 790 | """ 791 | c = self._current() 792 | if c is None: 793 | raise NoRecordException('cannot get a value from nothing, did you forget to call next() or initialize()?') 794 | if field not in c: 795 | if isinstance(value, GlideElement): 796 | c[field] = GlideElement(field, value.get_value(), value.get_display_value(), parent_record=self) 797 | else: 798 | c[field] = GlideElement(field, value, parent_record=self) 799 | else: 800 | c[field].set_value(value) 801 | 802 | def set_display_value(self, field, value): 803 | """ 804 | Set the display value for a field. 805 | 806 | :param str field: The field 807 | :param value: The Value 808 | """ 809 | c = self._current() 810 | if c is None: 811 | raise NoRecordException('cannot get a value from nothing, did you forget to call next() or initialize()?') 812 | if field not in c: 813 | c[field] = GlideElement(field, display_value=value, parent_record=self) 814 | else: 815 | c[field].set_display_value(value) 816 | 817 | def set_link(self, field, value): 818 | """ 819 | Set the link for a field, it is however preferable to to `gr.field.set_link(value)`. 820 | 821 | :param str field: The field 822 | :param value: The Value 823 | """ 824 | c = self._current() 825 | if c is None: 826 | raise NoRecordException('cannot get a value from nothing, did you forget to call next() or initialize()?') 827 | if field not in c: 828 | c[field] = GlideElement(field, link=value, parent_record=self) 829 | else: 830 | c[field].set_link(value) 831 | 832 | def get_link(self, no_stack: bool=False) -> str: 833 | """ 834 | Generate a full URL to the current record. sys_id will be -1 if there is no current record. 835 | 836 | :param bool no_stack: Default ``False``, adds ``&sysparm_stack=_list.do?sysparm_query=active=true`` to the URL 837 | :param bool list: Default ``False``, if ``True`` then provide a link to the record set, not the current record 838 | :return: The full URL to the current record 839 | :rtype: str 840 | """ 841 | ins = self._client.instance 842 | obj = self._current() 843 | stack = '&sysparm_stack=%s_list.do?sysparm_query=active=true' % self.__table 844 | if no_stack: 845 | stack = '' 846 | id = self.sys_id if obj else None 847 | id = id or '-1' 848 | return "{}/{}.do?sys_id={}{}".format(ins, self.__table, id, stack) 849 | 850 | def get_link_list(self) -> Optional[str]: 851 | """ 852 | Generate a full URL to for the current query. 853 | 854 | :return: The full URL to the record query 855 | """ 856 | ins = self._client.instance 857 | sysparm_query = self.get_encoded_query() 858 | url = "{}/{}_list.do".format(ins, self.__table) 859 | # Using `requests` as to be py2/3 agnostic and to encode the URL properly. 860 | return Request('GET', url, params=dict(sysparm_query=sysparm_query)).prepare().url 861 | 862 | def get_encoded_query(self) -> str: 863 | """ 864 | Generate the encoded query. Does not respect limits. 865 | 866 | :return: The encoded query, empty string if none exists 867 | """ 868 | return self.__query.generate_query(encoded_query=self.__encoded_query, order_by=self.__order) 869 | 870 | def get_unique_name(self) -> str: 871 | """ 872 | always give us the sys_id 873 | """ 874 | return self.get_value('sys_id') 875 | 876 | def get_attachments(self) -> 'Attachment': 877 | """ 878 | Get the attachments for the current record or the current table 879 | 880 | :return: A list of attachments 881 | :rtype: :class:`pysnc.Attachment` 882 | """ 883 | attachment = self._client.Attachment(self.__table) 884 | if self.sys_id: 885 | attachment.add_query('table_sys_id', self.sys_id) 886 | attachment.query() 887 | return attachment 888 | 889 | def add_attachment(self, file_name, file, content_type=None, encryption_context=None): 890 | if self._current() is None: 891 | raise NoRecordException('cannot add attachment to nothing, did you forget to call next() or initialize()?') 892 | 893 | attachment = self._client.Attachment(self.__table) 894 | return attachment.add_attachment(self.sys_id, file_name, file, content_type, encryption_context) 895 | 896 | def add_active_query(self) -> QueryCondition: 897 | """ 898 | Equivilant to the following:: 899 | 900 | add_query('active', 'true') 901 | 902 | """ 903 | return self.__query.add_active_query() 904 | 905 | def add_query(self, name, value, second_value=None) -> QueryCondition: 906 | """ 907 | Add a query to a record. For example:: 908 | 909 | add_query('active', 'true') 910 | 911 | Which will create the query ``active=true``. If we specify the second_value:: 912 | 913 | add_query('name', 'LIKE', 'test') 914 | 915 | Which will create the query ``nameLIKEtest`` 916 | 917 | 918 | :param str name: Table field name 919 | :param str value: Either the value in which ``name`` must be `=` to else an operator if ``second_value`` is specified 920 | 921 | Numbers:: 922 | 923 | * = 924 | * != 925 | * > 926 | * >= 927 | * < 928 | * <= 929 | 930 | Strings:: 931 | 932 | * = 933 | * != 934 | * IN 935 | * NOT IN 936 | * STARTSWITH 937 | * ENDSWITH 938 | * CONTAINS 939 | * DOES NOT CONTAIN 940 | * INSTANCEOF 941 | 942 | :param str second_value: optional, if specified then ``value`` is expected to be an operator 943 | """ 944 | return self.__query.add_query(name, value, second_value) 945 | 946 | def add_join_query(self, join_table, primary_field=None, join_table_field=None) -> JoinQuery: 947 | """ 948 | Do a join query:: 949 | 950 | gr = client.GlideRecord('sys_user') 951 | join_query = gr.add_join_query('sys_user_group', join_table_field='manager') 952 | join_query.add_query('active','true') 953 | gr.query() 954 | 955 | :param str join_table: The table to join against 956 | :param str primary_field: The current table field to use for the join. Default is ``sys_id`` 957 | :param str join_table_field: The ``join_Table`` field to use for the join 958 | :return: :class:`query.JoinQuery` 959 | """ 960 | return self.__query.add_join_query(join_table, primary_field, join_table_field) 961 | 962 | def add_rl_query(self, related_table, related_field, operator_condition, stop_at_relationship=False): 963 | """ 964 | Generates a 'Related List Query' which is defined as: 965 | 966 | RLQUERY.,[,m2m][^subquery]^ENDRLQUERY 967 | 968 | For example, when querying sys_user to simulate a LEFT OUTER JOIN to find active users with no manager: 969 | 970 | RLQUERYsys_user.manager,=0^active=true^ENDRLQUERY 971 | 972 | If we find users with a specific role: 973 | 974 | RLQUERYsys_user_has_role.user,>0^role=ROLESYSID^ENDRLQUERY 975 | 976 | But if we want to dotwalk the role (aka set stop_at_relationship=True): 977 | 978 | RLQUERYsys_user_has_role.user,>0,m2m^role.name=admin^ENDRLQUERY 979 | 980 | :param str related_table: The table with the relationship -- the other table 981 | :param str related_field: The field to use to relate from the other table to the table we are querying on 982 | :param str operator_condition: The operator to use to relate the two tables, as in `=0` or `>=1` -- this is not validated by pysnc 983 | :param bool stop_at_relationship: if we have a subquery (a query condition ON the RLQUERY) AND it dot walks, this must be True. Default is False. 984 | """ 985 | return self.__query.add_rl_query(related_table, related_field, operator_condition, stop_at_relationship) 986 | 987 | def add_encoded_query(self, encoded_query): 988 | """ 989 | Adds a raw query. Appends (comes after) all other defined queries e.g. :func:`add_query` 990 | 991 | :param str encoded_query: The same as ``sysparm_query`` 992 | """ 993 | 994 | self.__encoded_query = encoded_query 995 | 996 | def add_null_query(self, field) -> QueryCondition: 997 | """ 998 | If the specified field is empty 999 | Equivilant to the following:: 1000 | 1001 | add_query(field, '', 'ISEMPTY') 1002 | 1003 | :param str field: The field to validate 1004 | """ 1005 | return self.__query.add_null_query(field) 1006 | 1007 | def add_not_null_query(self, field) -> QueryCondition: 1008 | """ 1009 | If the specified field is `not` empty 1010 | Equivilant to the following:: 1011 | 1012 | add_query(field, '', 'ISNOTEMPTY') 1013 | 1014 | :param str field: The field to validate 1015 | """ 1016 | return self.__query.add_not_null_query(field) 1017 | 1018 | def _serialize(self, record, display_value, fields=None, changes_only=False, exclude_reference_link=True): 1019 | if isinstance(display_value, str): 1020 | v_type = 'both' 1021 | else: 1022 | v_type = 'display_value' if display_value else 'value' 1023 | 1024 | def compress(obj): 1025 | ret = dict() 1026 | if not obj: 1027 | return None 1028 | for key, value in obj.items(): 1029 | if fields and key not in fields: 1030 | continue 1031 | if isinstance(value, GlideElement): 1032 | if changes_only and not value.changes(): 1033 | continue 1034 | if exclude_reference_link or value.get_link() is None: 1035 | if v_type == 'display_value': 1036 | ret[key] = value.get_display_value() 1037 | elif v_type == 'both': 1038 | ret[key] = value.serialize() 1039 | else: 1040 | ret[key] = value.get_value() 1041 | else: 1042 | serialized = value.serialize() 1043 | if v_type == 'display_value': 1044 | serialized.pop('value', None) 1045 | elif v_type == 'value': 1046 | serialized.pop('display_value', None) 1047 | ret[key] = serialized 1048 | else: 1049 | ret[key] = value.get_value() 1050 | return ret 1051 | 1052 | return compress(record) 1053 | 1054 | def serialize(self, display_value=False, fields=None, fmt=None, changes_only=False, exclude_reference_link=True) -> Any: 1055 | """ 1056 | Turn current record into a dictGlideRecord(None, 'incident') 1057 | 1058 | :param display_value: ``True``, ``False``, or ``'both'`` 1059 | :param list fields: Fields to serialize. Defaults to all fields. 1060 | :param str fmt: None or ``pandas``. Defaults to None 1061 | :param changes_only: Do we want to serialize only the fields we've modified? 1062 | :param exclude_reference_link: Do we want to exclude the reference link? default is True 1063 | :return: dict representation 1064 | """ 1065 | if fmt == 'pandas': 1066 | self._log.warning('Pandas serialize format is depricated') 1067 | # Pandas format 1068 | def transform(obj): 1069 | # obj == GlideRecord 1070 | ret = dict(sys_class_name=self.table) 1071 | for f in obj.fields: 1072 | if f == 'sys_id': 1073 | ret['sys_id'] = obj.get_value(f) 1074 | else: 1075 | # value 1076 | ret['%s__value' % f] = obj.get_value(f) 1077 | # display value 1078 | ret['%s__display' % f] = obj.get_display_value(f) 1079 | return ret 1080 | return transform(self) # i know this is inconsistent, self vs current 1081 | else: 1082 | c = self._current() 1083 | return self._serialize(c, display_value, fields, changes_only, exclude_reference_link) 1084 | 1085 | def serialize_all(self, display_value=False, fields=None, fmt=None, exclude_reference_link=True) -> list: 1086 | """ 1087 | Serialize the entire query. See serialize() docs for details on parameters 1088 | 1089 | :param display_value: 1090 | :param fields: 1091 | :param fmt: 1092 | :return: list 1093 | """ 1094 | return [record.serialize(display_value=display_value, fields=fields, fmt=fmt, exclude_reference_link=exclude_reference_link) for record in self] 1095 | 1096 | def to_pandas(self, columns=None, mode='smart'): 1097 | """ 1098 | This is similar to serialize_all, but we by default include a table column and split into `__value`/`__display` if 1099 | the values are different (mode == `smart`). Other modes include `both`, `value`, and `display` in which behavior 1100 | follows their name. 1101 | 1102 | ``` 1103 | df = pd.DataFrame(gr.to_pandas()) 1104 | ``` 1105 | 1106 | Note: it is highly recommended you first restrict the number of columns generated by settings :func:`fields` first. 1107 | 1108 | :param mode: How do we want to serialize the data, options are `smart`, `both`, `value`, `display` 1109 | :rtype: tuple 1110 | :return: ``(list, list)`` inwhich ``(data, fields)`` 1111 | """ 1112 | fres = [] 1113 | if mode == 'smart': 1114 | for f in self.fields: 1115 | column_equals = True 1116 | self.rewind() 1117 | while (self.next() and column_equals): 1118 | v = self.get_value(f) 1119 | d = self.get_display_value(f) 1120 | #print(f'{v} == {d} ? {v == d}') 1121 | column_equals &= (v == d) 1122 | if column_equals: 1123 | fres.append(f) 1124 | else: 1125 | fres.append('%s__value' % f) 1126 | fres.append('%s__display' % f) 1127 | elif mode == 'both': 1128 | for f in self.fields: 1129 | fres.append('%s__value' % f) 1130 | fres.append('%s__display' % f) 1131 | else: 1132 | fres = self.fields 1133 | 1134 | if columns: 1135 | assert len(fres) == len(columns) 1136 | 1137 | data = OrderedDict({k:[] for k in fres}) 1138 | 1139 | if len(self.fields) > 20: 1140 | self._log.warning("Generating data for a large number of columns (>20) - consider limiting fields") 1141 | 1142 | for gr in self: 1143 | if mode == 'value': 1144 | for f in fres: 1145 | data[f].append(gr.get_value(f)) 1146 | elif mode == 'display': 1147 | for f in fres: 1148 | data[f].append(gr.get_display_value(f)) 1149 | else: 1150 | for f in fres: 1151 | field = f.split('__') 1152 | if len(field) == 2: 1153 | if field[1] == 'value': 1154 | data[f].append(gr.get_value(field[0])) 1155 | elif field[1] == 'display': 1156 | data[f].append(gr.get_display_value(field[0])) 1157 | else: 1158 | data[f].append(gr.get_display_value(field[0])) 1159 | 1160 | if columns: 1161 | # update keys 1162 | return OrderedDict((c, v) for (c, (k, v)) in zip(columns, data.items())) 1163 | 1164 | return data 1165 | 1166 | def _is_rewindable(self) -> bool: 1167 | return self.__rewindable 1168 | 1169 | def __iter__(self): 1170 | self.__is_iter = True 1171 | if self._is_rewindable(): 1172 | self.rewind() 1173 | return self 1174 | 1175 | def __next__(self): 1176 | return self.next() 1177 | 1178 | def next(self, _recursive=False) -> bool: 1179 | """ 1180 | Returns the next record in the record set 1181 | 1182 | :return: ``True`` or ``False`` based on success 1183 | """ 1184 | l = len(self.__results) 1185 | if l > 0 and self.__current+1 < l: 1186 | self.__current = self.__current + 1 1187 | if self.__is_iter: 1188 | if not self._is_rewindable(): # if we're not rewindable, remove the previous record 1189 | self.__results[self.__current - 1] = None 1190 | return self # type: ignore # this typing is internal only 1191 | return True 1192 | if self.__total and self.__total > 0 and \ 1193 | (self.__current+1) < self.__total and \ 1194 | self.__total > len(self.__results) and \ 1195 | _recursive is False: 1196 | if self.__limit: 1197 | if self.__current+1 < self.__limit: 1198 | self._do_query() 1199 | return self.next(_recursive=True) 1200 | else: 1201 | self._do_query() 1202 | return self.next(_recursive=True) 1203 | if self.__is_iter: 1204 | self.__is_iter = False 1205 | raise StopIteration() 1206 | return False 1207 | 1208 | def has_next(self) -> bool: 1209 | """ 1210 | Do we have a next record in the iteration? 1211 | 1212 | :return: ``True`` or ``False`` 1213 | """ 1214 | l = len(self.__results) 1215 | if l > 0 and self.__current + 1 < l: 1216 | return True 1217 | return False 1218 | 1219 | def _transform_result(self, result): 1220 | for key, value in result.items(): 1221 | result[key] = GlideElement(key, value, parent_record=self) 1222 | return result 1223 | 1224 | def __str__(self): 1225 | return """{}({})""".format( 1226 | self.__table, 1227 | self._serialize(self._current(), False) 1228 | ) 1229 | 1230 | def __setattr__(self, key, value): 1231 | if key.startswith('_'): 1232 | # Obviously internal 1233 | super(GlideRecord, self).__setattr__(key, value) 1234 | else: 1235 | propobj = getattr(self.__class__, key, None) 1236 | if isinstance(propobj, property): 1237 | if propobj.fset is None: 1238 | raise AttributeError("can't set attribute") 1239 | propobj.fset(self, value) 1240 | else: 1241 | self.set_value(key, value) 1242 | 1243 | def __getattr__(self, item): 1244 | # TODO: allow override for record fields which may overload our local properties by prepending _ 1245 | obj = self._current() 1246 | if obj: 1247 | return self.get_element(item) 1248 | #return self.get_value(item) 1249 | return self.__getattribute__(item) 1250 | 1251 | def __contains__(self, item): 1252 | obj = self._current() 1253 | if obj: 1254 | return item in obj 1255 | return False 1256 | 1257 | 1258 | 1259 | -------------------------------------------------------------------------------- /pysnc/utils.py: -------------------------------------------------------------------------------- 1 | from .exceptions import * 2 | 3 | 4 | def get_instance(instance): 5 | """ 6 | Return a well formed instance or raise 7 | 8 | :param instance: A string 9 | :return: The full instance URL 10 | :raise: InstanceException 11 | """ 12 | if '://' in instance: 13 | instance = instance.rstrip('/') 14 | if instance.startswith('http://'): 15 | raise InstanceException("Must provide https:// url not http://") 16 | return instance 17 | if '.' not in instance: 18 | return 'https://%s.service-now.com' % instance 19 | 20 | raise InstanceException("Instance name not well-formed. Pass a full URL or instance name.") 21 | 22 | 23 | class MockHeaders: 24 | def __init__(self, headers): 25 | self._headers = headers 26 | 27 | def getheaders(self, name): 28 | return self._headers[name] 29 | 30 | def get_all(self, name, default): 31 | return getattr(self._headers, name, default) -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | PYSNC_SERVER=https://dev00000.service-now.com 2 | PYSNC_USERNAME=admin 3 | PYSNC_PASSWORD=pysnc 4 | PYSNC_CLIENT_ID=1234 5 | PYSNC_CLIENT_SECRET=abcd -------------------------------------------------------------------------------- /test/constants.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | from dotenv import load_dotenv 4 | import warnings 5 | import logging 6 | 7 | warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) 8 | logging.getLogger("urllib3").propagate = False # get rid of all the extra test logging junk 9 | logging.getLogger('requests_oauthlib').propagate = False 10 | 11 | 12 | class Constants(object): 13 | 14 | _server = None 15 | _username = 'admin' 16 | _plugin = None 17 | 18 | def __init__(self): 19 | load_dotenv() 20 | self._settings = {} 21 | 22 | @property 23 | def password(self): 24 | try: 25 | if os.path.exists('.password'): 26 | with open('.password', 'r') as f: 27 | return f.read().strip() 28 | except: 29 | pass 30 | pw = self.get_value('password') 31 | if pw: 32 | return pw 33 | return getpass.getpass('\nPassword: ') 34 | 35 | @property 36 | def username(self): 37 | return self.get_value('username') 38 | 39 | @property 40 | def credentials(self): 41 | return (self.username, self.get_value('password')) 42 | 43 | @property 44 | def server(self): 45 | return self.get_value('server') 46 | 47 | @property 48 | def plugin(self): 49 | if 'plugin' in self._settings: 50 | return self._settings['plugin'] 51 | return self._plugin 52 | 53 | def get_value(self, name): 54 | if name in self._settings: 55 | return self._settings[name] 56 | return os.environ[f"PYSNC_{name.replace('-','_')}".upper()] 57 | -------------------------------------------------------------------------------- /test/test_pebcak.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from pysnc.exceptions import * 5 | from constants import Constants 6 | 7 | 8 | class TestPEBCAK(TestCase): 9 | c = Constants() 10 | 11 | def test_table(self): 12 | client = ServiceNowClient(self.c.server, self.c.credentials) 13 | gr = client.GlideRecord('sys_user_list') 14 | client.session.close() 15 | self.assertRaises(RequestException, gr.get, 'doesntmatter') 16 | 17 | def test_instance(self): 18 | with self.assertRaises(InstanceException) as context: 19 | client = ServiceNowClient('test.x', self.c.credentials) 20 | self.assertTrue(isinstance(context.exception, InstanceException)) 21 | 22 | def test_creds(self): 23 | with self.assertRaises(AuthenticationException) as context: 24 | client = ServiceNowClient(self.c.server, ('test','test')) 25 | gr = client.GlideRecord('sys_user') 26 | gr.get('asdf') 27 | self.assertTrue(isinstance(context.exception, AuthenticationException)) 28 | 29 | def test_no_result_without_query(self): 30 | client = ServiceNowClient(self.c.server, self.c.credentials) 31 | gr = client.GlideRecord('sys_user') 32 | gr.add_query('sys_id', 'bunk') 33 | self.assertFalse(gr.has_next()) 34 | for e in gr: 35 | assert "Should not have iterated!" 36 | client.session.close() 37 | 38 | def test_forgot_to_iterate(self): 39 | client = ServiceNowClient(self.c.server, self.c.credentials) 40 | gr = client.GlideRecord('sys_user') 41 | gr.limit = 1 42 | gr.query() 43 | 44 | self.assertRaises(AttributeError, lambda: gr.sys_id) 45 | self.assertRaises(NoRecordException, lambda: gr.set_value('sys_id', 1)) 46 | self.assertRaises(NoRecordException, lambda: gr.get_value('sys_id')) 47 | 48 | -------------------------------------------------------------------------------- /test/test_snc_api.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient, exceptions 4 | from constants import Constants 5 | 6 | class TestAuditScoped(TestCase): 7 | c = Constants() 8 | 9 | def test_connect(self): 10 | client = ServiceNowClient(self.c.server, self.c.credentials) 11 | gr = client.GlideRecord('sys_user') 12 | r = gr.get('6816f79cc0a8016401c5a33be04be441') 13 | self.assertEqual(r, True) 14 | client.session.close() 15 | 16 | def test_link(self): 17 | client = ServiceNowClient(self.c.server, self.c.credentials) 18 | gr = client.GlideRecord('sys_user') 19 | gr.get('6816f79cc0a8016401c5a33be04be441') 20 | link = gr.get_link(no_stack=True) 21 | self.assertTrue(link.endswith('sys_user.do?sys_id=6816f79cc0a8016401c5a33be04be441')) 22 | link = gr.get_link() 23 | self.assertTrue(link.endswith('sys_user.do?sys_id=6816f79cc0a8016401c5a33be04be441&sysparm_stack=sys_user_list.do?sysparm_query=active=true')) 24 | client.session.close() 25 | 26 | def test_link_query(self): 27 | client = ServiceNowClient(self.c.server, self.c.credentials) 28 | gr = client.GlideRecord('sys_user') 29 | gr.limit = 5; 30 | gr.query() 31 | link = gr.get_link(no_stack=True) 32 | print(link) 33 | self.assertTrue(link.endswith('sys_user.do?sys_id=-1')) 34 | self.assertTrue(gr.next()) 35 | link = gr.get_link(no_stack=True) 36 | print(link) 37 | self.assertFalse(link.endswith('sys_user.do?sys_id=-1')) 38 | client.session.close() 39 | 40 | def test_link_list(self): 41 | client = ServiceNowClient(self.c.server, self.c.credentials) 42 | gr = client.GlideRecord('sys_user') 43 | gr.add_active_query() 44 | gr.add_query("name","CONTAINS","a") 45 | link = gr.get_link_list() 46 | print(link) 47 | self.assertTrue(link.endswith('sys_user_list.do?sysparm_query=active%3Dtrue%5EnameCONTAINSa%5EORDERBYsys_id')) 48 | client.session.close() 49 | 50 | 51 | def test_next(self): 52 | client = ServiceNowClient(self.c.server, self.c.credentials) 53 | gr = client.GlideRecord('sys_user') 54 | gr.add_active_query() 55 | gr.limit = 2 56 | gr.query() 57 | #print(gr.serialize_all()) 58 | self.assertTrue(gr.next()) 59 | self.assertTrue(gr.has_next()) 60 | self.assertTrue(gr.next()) 61 | self.assertFalse(gr.has_next()) 62 | client.session.close() 63 | 64 | def test_proxy(self): 65 | proxy = 'http://localhost:4444' 66 | obj = {'http': 'http://localhost:4444', 'https': 'http://localhost:4444'} 67 | client = ServiceNowClient(self.c.server, self.c.credentials, proxy=proxy) 68 | self.assertEqual(client.session.proxies, obj) 69 | client = ServiceNowClient(self.c.server, self.c.credentials, proxy=obj) 70 | self.assertEqual(client.session.proxies, obj) 71 | client.session.close() 72 | 73 | def test_len(self): 74 | client = ServiceNowClient(self.c.server, self.c.credentials) 75 | gr = client.GlideRecord('sys_user') 76 | self.assertEqual(len(gr), 0) 77 | self.assertEqual(gr.get_row_count(), 0) 78 | gr.query() 79 | self.assertGreater(len(gr), 0) 80 | self.assertGreater(gr.get_row_count(), 0) 81 | client.session.close() 82 | 83 | def test_http_url(self): 84 | self.assertRaises(exceptions.InstanceException, 85 | lambda: ServiceNowClient('http://bunk.service-now.com', self.c.credentials)) 86 | 87 | -------------------------------------------------------------------------------- /test/test_snc_api_fields.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from constants import Constants 5 | from pysnc.record import GlideElement 6 | 7 | class TestRecordFields(TestCase): 8 | c = Constants() 9 | 10 | def setUp(self): 11 | self.client = ServiceNowClient(self.c.server, self.c.credentials) 12 | 13 | def tearDown(self): 14 | self.client.session.close() 15 | self.client = None 16 | 17 | def test_field_limit(self): 18 | gr = self.client.GlideRecord('sys_user') 19 | gr.fields = 'sys_id,name' 20 | r = gr.get('6816f79cc0a8016401c5a33be04be441') 21 | 22 | print(gr.serialize()) 23 | self.assertEqual(r, True) 24 | sobj = gr.serialize() 25 | self.assertTrue('sys_id' in sobj) 26 | self.assertFalse('sys_created_on' in sobj) 27 | 28 | def test_field_limit_query(self): 29 | gr = self.client.GlideRecord('sys_user') 30 | gr.limit = 1 31 | gr.fields = 'sys_id,name' 32 | gr.query() 33 | gr.next() 34 | 35 | print(gr.serialize()) 36 | sobj = gr.serialize() 37 | self.assertTrue('sys_id' in sobj) 38 | self.assertFalse('sys_created_on' in sobj) 39 | 40 | def test_field_bool(self): 41 | gr = self.client.GlideRecord('sys_user') 42 | gr.fields = 'sys_id,active' 43 | gr.get('6816f79cc0a8016401c5a33be04be441') 44 | 45 | print(gr.serialize()) 46 | 47 | self.assertEqual(gr.active, 'true') 48 | 49 | def test_field_access(self): 50 | gr = self.client.GlideRecord('sys_user') 51 | gr.fields = 'sys_id,name' 52 | gr.get('6816f79cc0a8016401c5a33be04be441') 53 | 54 | print(gr.serialize()) 55 | 56 | name = 'System Administrator' 57 | self.assertEqual(gr.name, name) 58 | self.assertEqual(gr.get_value('name'), name) 59 | self.assertEqual(gr.get_display_value('name'), name) 60 | 61 | def test_field_contains(self): 62 | gr = self.client.GlideRecord('sys_user') 63 | gr.fields = 'sys_id,name' 64 | gr.get('6816f79cc0a8016401c5a33be04be441') 65 | print(gr.serialize()) 66 | self.assertTrue('name' in gr) 67 | self.assertFalse('whatever' in gr) 68 | 69 | def test_field_set(self): 70 | gr = self.client.GlideRecord('sys_user') 71 | gr.fields = 'sys_id,name' 72 | gr.get('6816f79cc0a8016401c5a33be04be441') 73 | print(gr.serialize()) 74 | name = 'System Administrator' 75 | self.assertEqual(gr.name, name) 76 | gr.name = 'Whatever' 77 | self.assertEqual(gr.name, 'Whatever') 78 | gr.set_value('name', 'Test') 79 | self.assertEqual(gr.name, 'Test') 80 | self.assertEqual(gr.get_value('name'), 'Test') 81 | 82 | def test_field_set_init(self): 83 | gr = self.client.GlideRecord('sys_user') 84 | gr.initialize() 85 | name = 'System Administrator' 86 | gr.name = name 87 | self.assertEqual(gr.name, name) 88 | gr.set_value('name', 'Test') 89 | self.assertEqual(gr.name, 'Test') 90 | self.assertEqual(gr.get_value('name'), 'Test') 91 | 92 | def test_fields(self): 93 | gr = self.client.GlideRecord('sys_user') 94 | gr.fields = ['sys_id'] 95 | gr.limit = 4 96 | gr.query() 97 | count = 0 98 | while gr.next(): 99 | count = count + 1 100 | assert len(gr._current().keys()) == 1 101 | self.assertEqual(count, 4) 102 | 103 | def test_field_getter(self): 104 | gr = self.client.GlideRecord('sys_user') 105 | gr.fields = ['sys_id'] 106 | self.assertEqual(gr.fields, ['sys_id']) 107 | 108 | def test_field_all(self): 109 | gr = self.client.GlideRecord('sys_user') 110 | self.assertIsNone(gr.fields) 111 | gr.query() 112 | self.assertIsNotNone(gr.fields) 113 | 114 | def test_field_getter_query(self): 115 | gr = self.client.GlideRecord('sys_user') 116 | self.assertEqual(gr.fields, None) 117 | gr.limit = 1 118 | gr.query() 119 | self.assertIsNotNone(gr.fields) 120 | self.assertGreater(len(gr.fields), 10) 121 | gr.next() 122 | print(gr.fields) 123 | self.assertGreater(len(gr.fields), 10) 124 | 125 | def test_boolean(self): 126 | gr = self.client.GlideRecord('sys_user') 127 | gr.fields = ['sys_id', 'active'] 128 | gr.query() 129 | self.assertTrue(gr.next()) 130 | # as a string, because that's the actual JSON response value 131 | self.assertEqual(gr.active, 'true') 132 | self.assertEqual(gr.get_value('active'), 'true') 133 | self.assertEqual(gr.get_display_value('active'), 'true') 134 | self.assertEqual(gr.get_element('active'), 'true') 135 | self.assertTrue(bool(gr.active)) 136 | if not gr.active: 137 | assert 'should have been true' 138 | gr.active = 'false' 139 | print(repr(gr.active)) 140 | self.assertFalse(bool(gr.active)) 141 | if gr.active: 142 | assert 'should have been false' 143 | 144 | 145 | def test_attrs(self): 146 | gr = self.client.GlideRecord('sys_user') 147 | r = gr.get('6816f79cc0a8016401c5a33be04be441') 148 | self.assertEqual(r, True) 149 | self.assertEqual(gr.sys_id, '6816f79cc0a8016401c5a33be04be441') 150 | self.assertEqual(gr.get_value('sys_id'), '6816f79cc0a8016401c5a33be04be441') 151 | self.assertEqual(gr.get_display_value('user_password'), '********') 152 | 153 | def test_attrs_nil(self): 154 | gr = self.client.GlideRecord('sys_user') 155 | r = gr.get('6816f79cc0a8016401c5a33be04be441') 156 | self.assertEqual(r, True) 157 | self.assertIsNotNone(gr.get_element('sys_id')) 158 | self.assertIsNone(gr.get_element('asdf')) 159 | self.assertFalse(gr.get_element('sys_id').nil()) 160 | self.assertFalse(gr.sys_id.nil()) 161 | gr.sys_id = '' 162 | self.assertTrue(gr.get_element('sys_id').nil()) 163 | self.assertTrue(gr.sys_id.nil()) 164 | 165 | def test_attrs_changes(self): 166 | gr = self.client.GlideRecord('sys_user') 167 | r = gr.get('6816f79cc0a8016401c5a33be04be441') 168 | self.assertEqual(r, True) 169 | self.assertIsNotNone(gr.get_element('sys_id')) 170 | self.assertIsNone(gr.get_element('asdf')) 171 | self.assertEqual(gr.get_element('sys_id').changes(), False) 172 | gr.sys_id = '1234' 173 | self.assertEqual(gr.get_element('sys_id').changes(), True) 174 | 175 | def test_attrs_changes(self): 176 | gr = self.client.GlideRecord('sys_user') 177 | gr.initialize() 178 | self.assertTrue(gr.is_new_record()) 179 | self.assertIsNone(gr.get_element('sys_id')) 180 | gr.sys_id = 'zzzz' 181 | # i am not considering a state of nothing to something a change, merely the start of existence 182 | self.assertEqual(gr.get_element('sys_id').changes(), False) 183 | gr.sys_id = '1234' 184 | self.assertEqual(gr.get_element('sys_id').changes(), True) 185 | 186 | def test_dotwalk_with_element(self): 187 | gr = self.client.GlideRecord('sys_user') 188 | gr.fields = 'sys_id,active,email,department,department.name,department.dept_head,department.dept_head.email' 189 | gr.get('6816f79cc0a8016401c5a33be04be441') 190 | print(gr.serialize(display_value='both')) 191 | 192 | self.assertEqual(gr.email, 'admin@example.com') 193 | 194 | self.assertEqual(gr.department, 'a581ab703710200044e0bfc8bcbe5de8') 195 | 196 | self.assertEqual(gr.department.name, 'Finance') 197 | 198 | self.assertEqual(gr.department.dept_head, '46c5bf6ca9fe1981010713e3ac7d3384') 199 | self.assertEqual(type(gr.department.dept_head), GlideElement) 200 | self.assertEqual(gr.department.dept_head.get_value(), '46c5bf6ca9fe1981010713e3ac7d3384') 201 | self.assertFalse(gr.department.dept_head.nil()) 202 | 203 | self.assertEqual(gr.department.dept_head.email, 'natasha.ingram@example.com') 204 | self.assertEqual(type(gr.department.dept_head.email), GlideElement) 205 | 206 | self.assertRaisesRegex(AttributeError, r'.+has no attribute.+nor GlideElement.+', lambda: gr.department.description) 207 | 208 | -------------------------------------------------------------------------------- /test/test_snc_api_query.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient, exceptions 4 | from constants import Constants 5 | 6 | class TestRecordQuery(TestCase): 7 | """ 8 | TODO: active query 9 | """ 10 | c = Constants() 11 | 12 | 13 | def test_batching(self): 14 | client = ServiceNowClient(self.c.server, self.c.credentials) 15 | gr = client.GlideRecord('syslog') 16 | gr.fields = ['sys_id'] # not testing this, but just limit response size 17 | gr.query() 18 | gr.limit = 1100 19 | count = 0 20 | while gr.next(): 21 | self.assertFalse(gr.is_new_record()) 22 | count = count + 1 23 | self.assertGreater(count, 600) 24 | client.session.close() 25 | 26 | def test_query_obj(self): 27 | client = ServiceNowClient(self.c.server, self.c.credentials) 28 | gr = client.GlideRecord('sys_db_object') 29 | qobj = gr.add_query('name', 'alm_asset') 30 | self.assertIsNotNone(qobj) 31 | client.session.close() 32 | 33 | def test_or_query(self): 34 | client = ServiceNowClient(self.c.server, self.c.credentials) 35 | gr = client.GlideRecord('sys_db_object') 36 | o = gr.add_query('name', 'alm_asset') 37 | o.add_or_condition('name', 'bsm_chart') 38 | gr.query() 39 | self.assertEqual(gr.get_row_count(), 2) 40 | client.session.close() 41 | 42 | def test_get_query(self): 43 | client = ServiceNowClient(self.c.server, self.c.credentials) 44 | gr = client.GlideRecord('sys_db_object') 45 | o = gr.add_query('name', 'alm_asset') 46 | o.add_or_condition('name', 'bsm_chart') 47 | enc_query = gr.get_encoded_query() 48 | self.assertEqual(enc_query, 'name=alm_asset^ORname=bsm_chart^ORDERBYsys_id') 49 | client.session.close() 50 | 51 | def test_get_query_two(self): 52 | client = ServiceNowClient(self.c.server, self.c.credentials) 53 | gr = client.GlideRecord('sys_user') 54 | gr.get('6816f79cc0a8016401c5a33be04be441') 55 | enc_query = gr.get_encoded_query() 56 | self.assertEqual(enc_query, 'ORDERBYsys_id') # always have default orderby 57 | client.session.close() 58 | 59 | def test_null_query(self): 60 | client = ServiceNowClient(self.c.server, self.c.credentials) 61 | gr_first = client.GlideRecord('sys_user') 62 | gr_first.fields = 'sys_id' 63 | gr_first.query() 64 | 65 | gr = client.GlideRecord('sys_user') 66 | gr.add_null_query('name') 67 | gr.query() 68 | self.assertNotEqual(gr.get_row_count(), gr_first.get_row_count()) 69 | client.session.close() 70 | 71 | def test_len(self): 72 | client = ServiceNowClient(self.c.server, self.c.credentials) 73 | gr_first = client.GlideRecord('sys_user') 74 | gr_first.fields = 'sys_id' 75 | gr_first.query() 76 | gr = client.GlideRecord('sys_user') 77 | gr.add_null_query('name') 78 | gr.query() 79 | self.assertNotEqual(len(gr), len(gr_first)) 80 | client.session.close() 81 | 82 | def test_len_nonzero(self): 83 | client = ServiceNowClient(self.c.server, self.c.credentials) 84 | gr = client.GlideRecord('sys_user') 85 | gr.add_not_null_query('mobile_phone') 86 | gr.query() 87 | self.assertLess(len(gr), 20) 88 | client.session.close() 89 | 90 | def test_not_null_query(self): 91 | client = ServiceNowClient(self.c.server, self.c.credentials) 92 | gr = client.GlideRecord('sys_user') 93 | gr.add_not_null_query('mobile_phone') 94 | gr.query() 95 | self.assertLess(gr.get_row_count(), 20) 96 | client.session.close() 97 | 98 | def test_double_query(self): 99 | client = ServiceNowClient(self.c.server, self.c.credentials) 100 | gr = client.GlideRecord('sys_user') 101 | gr.add_query('active','true') 102 | gr.add_encoded_query('test=what') 103 | 104 | query = gr.get_encoded_query() 105 | self.assertEqual(query, "active=true^test=what^ORDERBYsys_id") 106 | 107 | gr = client.GlideRecord('sys_user') 108 | gr.add_encoded_query('test=what') 109 | gr.add_query('active','true') 110 | 111 | query = gr.get_encoded_query() 112 | self.assertEqual(query, "active=true^test=what^ORDERBYsys_id") 113 | client.session.close() 114 | 115 | def test_get_true(self): 116 | client = ServiceNowClient(self.c.server, self.c.credentials) 117 | gr = client.GlideRecord('sys_user') 118 | self.assertTrue(gr.get('6816f79cc0a8016401c5a33be04be441')) 119 | client.session.close() 120 | 121 | def test_get_field_true(self): 122 | client = ServiceNowClient(self.c.server, self.c.credentials) 123 | gr = client.GlideRecord('sys_user') 124 | self.assertTrue(gr.get('sys_id', '6816f79cc0a8016401c5a33be04be441')) 125 | client.session.close() 126 | 127 | def test_get_false(self): 128 | client = ServiceNowClient(self.c.server, self.c.credentials) 129 | gr = client.GlideRecord('sys_user') 130 | self.assertFalse(gr.get('bunk')) 131 | client.session.close() 132 | 133 | def test_get_field_false(self): 134 | client = ServiceNowClient(self.c.server, self.c.credentials) 135 | gr = client.GlideRecord('sys_user') 136 | self.assertFalse(gr.get('sys_id', 'bunk')) 137 | client.session.close() 138 | 139 | def test_no_result_query(self): 140 | client = ServiceNowClient(self.c.server, self.c.credentials) 141 | gr = client.GlideRecord('sys_user') 142 | gr.add_query('sys_id', 'bunk') 143 | gr.query() 144 | self.assertFalse(gr.has_next()) 145 | for e in gr: 146 | assert "Should not have iterated!" 147 | client.session.close() 148 | 149 | def test_get_field_access_direct(self): 150 | client = ServiceNowClient(self.c.server, self.c.credentials) 151 | gr = client.GlideRecord('sys_user') 152 | self.assertTrue(gr.get('6816f79cc0a8016401c5a33be04be441')) 153 | self.assertEqual(gr.user_name, 'admin') 154 | client.session.close() 155 | 156 | def test_get_field_access(self): 157 | client = ServiceNowClient(self.c.server, self.c.credentials) 158 | gr = client.GlideRecord('sys_user') 159 | self.assertTrue(gr.get('sys_id', '6816f79cc0a8016401c5a33be04be441')) 160 | self.assertEqual(gr.user_name, 'admin') 161 | client.session.close() 162 | 163 | def test_import(self): 164 | from pysnc.query import Query 165 | from pysnc.query import QueryCondition 166 | from pysnc.query import BaseCondition 167 | class Junk(BaseCondition): 168 | pass 169 | j = Junk('name', 'operator') 170 | 171 | def test_code_query_one(self): 172 | from pysnc.query import Query 173 | client = ServiceNowClient(self.c.server, self.c.credentials) 174 | gr = client.GlideRecord('sys_user') 175 | q = Query() 176 | q.add_query('sys_id', '6816f79cc0a8016401c5a33be04be441') 177 | q.add_query('second', 'asdf') 178 | self.assertEqual(q.generate_query(), 'sys_id=6816f79cc0a8016401c5a33be04be441^second=asdf') 179 | self.assertEqual(gr.get_encoded_query(), 'ORDERBYsys_id') 180 | gr.query(q) 181 | self.assertEqual(len(gr), 1) 182 | self.assertEqual(gr.get_encoded_query(), 'ORDERBYsys_id') 183 | gr.add_encoded_query(q.generate_query()) 184 | self.assertEqual(gr.get_encoded_query(), 'sys_id=6816f79cc0a8016401c5a33be04be441^second=asdf^ORDERBYsys_id') 185 | gr.query() 186 | self.assertEqual(len(gr), 1) 187 | 188 | def test_extra_long_query(self): 189 | client = ServiceNowClient(self.c.server, self.c.credentials) 190 | 191 | true_id = '6816f79cc0a8016401c5a33be04be441' 192 | gr = client.GlideRecord('sys_user') 193 | self.assertTrue(gr.get(true_id), 'failed to get true_id') 194 | 195 | # make an extra long query... 196 | gr = client.GlideRecord('sys_user') 197 | for _ in range(2300): 198 | # designed to be 10 chars long including ^ 199 | gr.add_query('AAAA', 'BBBB') # 'AAAA=BBBB^' 200 | gr.add_query('sys_id', true_id) # i want this at the end of the query just to be sure 201 | self.assertGreater(len(gr.get_encoded_query()), 23000) 202 | gr.query() # would throw normally 203 | self.assertEqual(len(gr), 1) 204 | self.assertTrue(gr.next()) 205 | self.assertEqual(gr.sys_id, true_id) 206 | 207 | def test_disable_display_values(self): 208 | client = ServiceNowClient(self.c.server, self.c.credentials) 209 | gr = client.GlideRecord('sys_user') 210 | gr.display_value = False 211 | gr.limit = 1 212 | gr.query() 213 | self.assertTrue(gr.next()) 214 | #print(gr.serialize(display_value='all')) 215 | self.assertFalse(gr.sys_updated_on.nil()) 216 | ele = gr.sys_updated_on 217 | print(repr(ele)) 218 | self.assertEqual(ele.get_value(), ele.get_display_value(), 'expected timestamps to equal') 219 | 220 | gr = client.GlideRecord('sys_user') 221 | gr.display_value = True 222 | gr.limit = 1 223 | gr.query() 224 | gr.next() 225 | self.assertNotEqual(ele.get_value(), gr.get_value('sys_updated_on')) 226 | 227 | def test_nonjson_error(self): 228 | client = ServiceNowClient(self.c.server, self.c.credentials) 229 | 230 | super_long_non_existant_name = "A" * 23000 231 | gr = client.GlideRecord(super_long_non_existant_name) 232 | self.assertRaisesRegex(exceptions.RequestException, r'^.*', lambda: gr.get('doesntmatter')) 233 | 234 | def test_changes(self): 235 | client = ServiceNowClient(self.c.server, self.c.credentials) 236 | gr = client.GlideRecord('sys_user') 237 | gr.limit = 1 238 | gr.query() 239 | self.assertTrue(gr.next()) 240 | self.assertFalse(gr.changes()) 241 | gr.user_name = 'new name' 242 | self.assertTrue(gr.changes()) 243 | -------------------------------------------------------------------------------- /test/test_snc_api_query_advanced.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient, exceptions 4 | from constants import Constants 5 | 6 | class TestRecordQueryAdvanced(TestCase): 7 | c = Constants() 8 | 9 | 10 | def test_join_query(self): 11 | client = ServiceNowClient(self.c.server, self.c.credentials) 12 | gr = client.GlideRecord('sys_user') 13 | join_query = gr.add_join_query('sys_user_group', join_table_field='manager') 14 | join_query.add_query('active','true') 15 | self.assertEqual(gr.get_encoded_query(), 'JOINsys_user.sys_id=sys_user_group.manager!active=true') 16 | gr.query() 17 | self.assertGreater(gr.get_row_count(), 1) 18 | client.session.close() 19 | 20 | def test_join_query_2(self): 21 | client = ServiceNowClient(self.c.server, self.c.credentials) 22 | gr = client.GlideRecord('sys_user') 23 | join_query = gr.add_join_query('sys_user_has_role', join_table_field='user') 24 | join_query.add_query('role', '2831a114c611228501d4ea6c309d626d') 25 | self.assertEqual(gr.get_encoded_query(), 'JOINsys_user.sys_id=sys_user_has_role.user!role=2831a114c611228501d4ea6c309d626d') 26 | gr.query() 27 | gr.next() 28 | self.assertGreater(len(gr), 10) # demo data has a lot of admins 29 | self.assertLess(len(gr), 25) # but not THAT many 30 | 31 | def test_rl_query_manual(self): 32 | # simulate a left outter join by finding users with no roles 33 | client = ServiceNowClient(self.c.server, self.c.credentials) 34 | gr = client.GlideRecord('sys_user') 35 | gr.add_encoded_query('RLQUERYsys_user_has_role.user,=0^ENDRLQUERY') 36 | gr.query() 37 | self.assertGreater(gr.get_row_count(), 1) 38 | self.assertLess(gr.get_row_count(), 10) 39 | 40 | def test_rl_query_basic(self): 41 | # simulate a left outter join by finding users with no roles 42 | client = ServiceNowClient(self.c.server, self.c.credentials) 43 | gr = client.GlideRecord('sys_user') 44 | gr.add_rl_query('sys_user_has_role', 'user', '=0') 45 | self.assertEqual(gr.get_encoded_query(), 'RLQUERYsys_user_has_role.user,=0^ENDRLQUERY') 46 | gr.query() 47 | self.assertGreater(gr.get_row_count(), 1) 48 | self.assertLess(gr.get_row_count(), 10) 49 | 50 | def test_rl_query_advanced(self): 51 | client = ServiceNowClient(self.c.server, self.c.credentials) 52 | gr = client.GlideRecord('sys_user') 53 | qc = gr.add_rl_query('sys_user_has_role', 'user', '>0', True) 54 | qc.add_query('role.name', 'admin') 55 | self.assertEqual(gr.get_encoded_query(), 'RLQUERYsys_user_has_role.user,>0,m2m^role.name=admin^ENDRLQUERY') 56 | gr.query() 57 | self.assertGreater(gr.get_row_count(), 10) 58 | self.assertLess(gr.get_row_count(), 25) 59 | 60 | -------------------------------------------------------------------------------- /test/test_snc_api_write.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from constants import Constants 5 | 6 | 7 | class TestWrite(TestCase): 8 | c = Constants() 9 | 10 | def test_crud(self): 11 | client = ServiceNowClient(self.c.server, self.c.credentials) 12 | gr = client.GlideRecord('problem') 13 | gr.initialize() 14 | gr.short_description = "Unit Test - Insert" 15 | gr.description = "Second Field" 16 | gr.bunk_field = "Bunk Field" 17 | res = gr.insert() 18 | self.assertIsNotNone(res) 19 | # should have gotten the response back, ergo populated new fields 20 | self.assertIsNotNone(gr.opened_by) 21 | self.assertEqual(len(gr.opened_by), 32, 'expected opened_by to be a sys_id') 22 | self.assertNotEqual(gr.get_value('opened_by'), gr.get_display_value('opened_by')) # our name isnt a sys_id 23 | first_user_display = gr.get_display_value('opened_by') 24 | 25 | # We have validated inserting works, now can we update. 26 | # find us a user to change the opened_by field that isn't us 27 | user = client.GlideRecord('sys_user') 28 | user.add_query('sys_id', '!=', 'javascript:gs.getUserID()') 29 | user.query() 30 | self.assertTrue(user.next()) 31 | self.assertNotEqual(user.sys_id, gr.get_value('opened_by'), 'what this shouldnt happen') 32 | #print(f"new user id is {user.sys_id}") 33 | 34 | # actually update 35 | gr2 = client.GlideRecord('problem') 36 | self.assertTrue(gr2.get(res)) 37 | #print(f"pre-update {gr2.serialize(display_value='both')}") 38 | self.assertTrue(bool(gr2.active)) 39 | gr2.active = 'false' 40 | self.assertTrue(gr2.changes()) 41 | self.assertFalse(bool(gr2.active)) 42 | gr2.short_description = "ABCDEFG0123" 43 | gr2.opened_by = user.sys_id 44 | 45 | #print(f"mid-update {gr2.serialize(display_value='both')}") 46 | self.assertIsNotNone(gr2.update()) 47 | #print(f"post-update {gr2.serialize(display_value='both')}") 48 | 49 | # now we expect our record to be different, locally 50 | self.assertTrue(bool(gr2.active)) # server-side forces it to stay true 51 | self.assertEqual(gr2.short_description, 'ABCDEFG0123') 52 | self.assertEqual(gr2.opened_by, user.sys_id) 53 | self.assertNotEqual(gr2.get_display_value('opened_by'), first_user_display) 54 | self.assertEqual(gr2.get_display_value('opened_by'), user.get_display_value('name')) 55 | 56 | 57 | # and if we re-query 58 | gr3 = client.GlideRecord('problem') 59 | gr3.get(res) 60 | self.assertEqual(gr3.short_description, "ABCDEFG0123") 61 | self.assertEqual(gr3.get_display_value('opened_by'), user.get_display_value('name')) 62 | 63 | gr4 = gr3.pop_record() 64 | gr4.short_description = 'ZZZ123' 65 | self.assertTrue(gr4.update()) 66 | 67 | gr4 = gr3.pop_record() 68 | gr4.short_description = 'ZZZ123' 69 | self.assertTrue(gr4.update()) 70 | 71 | 72 | 73 | gr4 = gr3.pop_record() 74 | gr4.short_description = 'ZZZ123' 75 | self.assertTrue(gr4.update()) 76 | 77 | 78 | 79 | self.assertTrue(gr3.delete()) 80 | 81 | # make sure it is deleted 82 | gr4 = client.GlideRecord('problem') 83 | self.assertFalse(gr4.get(res)) 84 | client.session.close() 85 | 86 | def test_insert(self): 87 | # I want to ensure the records sys_id is updated 88 | client = ServiceNowClient(self.c.server, self.c.credentials) 89 | gr = client.GlideRecord('problem') 90 | gr.initialize() 91 | gr.short_description = "Unit Test - Test insert id update" 92 | self.assertIsNone(gr.sys_id) 93 | res = gr.insert() 94 | self.assertIsNotNone(res) 95 | self.assertIsNotNone(gr.sys_id) 96 | self.assertEqual(res, gr.sys_id) 97 | self.assertIsNotNone(gr.number) 98 | # make sure it exists 99 | gr2 = client.GlideRecord('problem') 100 | self.assertTrue(gr2.get(res)) 101 | self.assertEqual(gr2.number, gr.number) 102 | 103 | gr.delete() 104 | 105 | # make sure it is deleted 106 | gr4 = client.GlideRecord('problem') 107 | self.assertFalse(gr4.get(res)) 108 | client.session.close() 109 | 110 | def test_insert_custom_guid(self): 111 | client = ServiceNowClient(self.c.server, self.c.credentials) 112 | customsysid = 'AAAABBBBCCCCDDDDEEEEFFFF00001111' 113 | # make sure this id doesn't exist, first 114 | gr = client.GlideRecord('problem') 115 | if gr.get(customsysid): 116 | gr.delete() 117 | # I want to ensure the records sys_id is updated 118 | gr = client.GlideRecord('problem') 119 | gr.initialize() 120 | gr.set_new_guid_value(customsysid) 121 | gr.short_description = "Unit Test - Test insert id update" 122 | res = gr.insert() 123 | self.assertIsNotNone(res) 124 | self.assertIsNotNone(gr.sys_id) 125 | self.assertEqual(res, customsysid) 126 | # make sure it exists 127 | gr2 = client.GlideRecord('problem') 128 | self.assertTrue(gr2.get(customsysid)) 129 | 130 | gr.delete() 131 | 132 | # make sure it is deleted 133 | gr4 = client.GlideRecord('problem') 134 | self.assertFalse(gr4.get(res)) 135 | client.session.close() 136 | 137 | def test_object_setter(self): 138 | client = ServiceNowClient(self.c.server, self.c.credentials) 139 | gr = client.GlideRecord('problem') 140 | gr.initialize() 141 | gr.name = 'aaaa' 142 | self.assertEqual(gr.name, 'aaaa') 143 | gr.roles = [1,2,3] 144 | self.assertEqual(gr.roles, [1,2,3]) 145 | client.session.close() 146 | 147 | def test_object_secondary_field(self): 148 | client = ServiceNowClient(self.c.server, self.c.credentials) 149 | gr = client.GlideRecord('sys_user') 150 | gr.limit = 1 151 | gr.query() 152 | self.assertTrue(gr.next()) 153 | gr.boom = 'aaaa' 154 | self.assertEqual(gr.boom, 'aaaa') 155 | gr.bye = [1,2,3] 156 | self.assertEqual(gr.bye, [1,2,3]) 157 | client.session.close() 158 | 159 | def test_multi_delete(self): 160 | client = ServiceNowClient(self.c.server, self.c.credentials) 161 | gr = client.GlideRecord('problem') 162 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 163 | gr.delete_multiple() # try to make sure weh ave none first 164 | 165 | # insert five bunk records 166 | for i in range(5): 167 | gr = client.GlideRecord('problem') 168 | gr.initialize() 169 | gr.short_description = f"Unit Test - BUNKZZ Multi Delete {i}" 170 | assert gr.insert(), "Failed to insert a record" 171 | 172 | # now make sure they exist... 173 | gr = client.GlideRecord('problem') 174 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 175 | gr.query() 176 | 177 | self.assertEqual(len(gr), 5) 178 | 179 | # now multi delete... 180 | gr = client.GlideRecord('problem') 181 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 182 | self.assertTrue(gr.delete_multiple()) 183 | 184 | # check again 185 | gr = client.GlideRecord('problem') 186 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 187 | gr.query() 188 | 189 | self.assertEqual(len(gr), 0) 190 | client.session.close() 191 | 192 | def test_multi_update(self): 193 | client = ServiceNowClient(self.c.server, self.c.credentials) 194 | 195 | gr = client.GlideRecord('problem') 196 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 197 | gr.query() 198 | gr.delete_multiple() # try to make sure weh ave none first 199 | gr.query() 200 | self.assertEqual(len(gr), 0) 201 | 202 | 203 | total_count = 10 204 | # insert five bunk records 205 | for i in range(total_count): 206 | gr = client.GlideRecord('problem') 207 | gr.initialize() 208 | gr.short_description = f"Unit Test - BUNKZZ Multi Delete {i}" 209 | assert gr.insert(), "Failed to insert a record" 210 | 211 | # now make sure they exist... 212 | gr = client.GlideRecord('problem') 213 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 214 | gr.query() 215 | self.assertEqual(len(gr), total_count) 216 | 217 | # make sure our 'new' ones arent here to throw it off 218 | tgr = client.GlideRecord('problem') 219 | tgr.add_query('short_description', 'LIKE', 'APPENDEDZZ') 220 | tgr.query() 221 | self.assertEqual(len(tgr), 0) 222 | 223 | while gr.next(): 224 | gr.short_description = gr.short_description + ' -- APPENDEDZZ' 225 | 226 | gr.update_multiple() 227 | 228 | tgr = client.GlideRecord('problem') 229 | tgr.add_query('short_description', 'LIKE', 'APPENDEDZZ') 230 | tgr.query() 231 | self.assertEqual(len(tgr), total_count) 232 | 233 | # make sure we only send changed ones... 234 | expected_to_change = [] 235 | for i, r in enumerate(tgr): 236 | if i % 2 == 0: 237 | r.short_description = r.short_description + ' even' 238 | expected_to_change.append(r.get_value('sys_id')) 239 | self.assertTrue(r.changes()) 240 | else: 241 | self.assertFalse(r.changes()) 242 | 243 | saw_change = [] 244 | def custom_handle(response): 245 | nonlocal saw_change 246 | self.assertEqual(response.status_code, 200) 247 | saw_change.append(response.json()['result']['sys_id']['value']) 248 | 249 | tgr.update_multiple(custom_handle) 250 | print(saw_change) 251 | self.assertCountEqual(saw_change, expected_to_change) 252 | self.assertListEqual(saw_change, expected_to_change) 253 | 254 | tgr.delete_multiple() 255 | client.session.close() 256 | 257 | def test_multi_update_with_failures(self): 258 | client = ServiceNowClient(self.c.server, self.c.credentials) 259 | br = client.GlideRecord('sys_script') 260 | 261 | # in order to test the failure case, let's insert a BR that will reject specific values 262 | br.add_query('name', 'test_multi_update_with_failures') 263 | br.query() 264 | if not br.next(): 265 | br.initialize() 266 | br.name = 'test_multi_update_with_failures' 267 | br.collection = 'problem' 268 | br.active = True 269 | br.when = 'before' 270 | br.order = 100 271 | br.action_insert = True 272 | br.action_update = True 273 | br.abort_action = True 274 | br.add_message = True 275 | br.message = 'rejected by test_multi_update_with_failures br' 276 | br.filter_condition = 'short_descriptionLIKEEICAR^ORdescriptionLIKEEICAR^EQ' 277 | br.insert() 278 | 279 | 280 | gr = client.GlideRecord('problem') 281 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 282 | gr.query() 283 | self.assertTrue(gr.delete_multiple()) # try to make sure weh ave none first 284 | gr.query() 285 | self.assertEqual(len(gr), 0, 'should have had none left') 286 | 287 | 288 | total_count = 10 289 | # insert five bunk records 290 | # TODO: insert multiple 291 | for i in range(total_count): 292 | gr = client.GlideRecord('problem') 293 | gr.initialize() 294 | gr.short_description = f"Unit Test - BUNKZZ Multi update {i}" 295 | assert gr.insert(), "Failed to insert a record" 296 | 297 | gr = client.GlideRecord('problem') 298 | gr.add_query('short_description', 'LIKE', 'BUNKZZ') 299 | gr.query() 300 | self.assertEqual(len(gr), total_count) 301 | 302 | i = 0 303 | # half append 304 | print(f"for i < {total_count//2}") 305 | while i < (total_count//2) and gr.next(): 306 | gr.short_description = gr.short_description + ' -- APPENDEDZZ' 307 | i += 1 308 | # half error 309 | while gr.next(): 310 | gr.short_description = gr.short_description + ' -- EICAR' 311 | 312 | self.assertFalse(gr.update_multiple()) 313 | # make sure we cleaned up as expected 314 | self.assertEqual(gr._client.batch_api._BatchAPI__hooks, {}) 315 | self.assertEqual(gr._client.batch_api._BatchAPI__stored_requests, {}) 316 | self.assertEqual(gr._client.batch_api._BatchAPI__requests, []) 317 | 318 | tgr = client.GlideRecord('problem') 319 | tgr.add_query('short_description', 'LIKE', 'APPENDEDZZ') 320 | tgr.query() 321 | self.assertEqual(len(tgr), total_count//2) 322 | 323 | tgr = client.GlideRecord('problem') 324 | tgr.add_query('short_description', 'LIKE', 'EICAR') 325 | tgr.query() 326 | self.assertEqual(len(tgr), 0) 327 | 328 | -------------------------------------------------------------------------------- /test/test_snc_attachment.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from constants import Constants 5 | from utils import TempTestRecord 6 | 7 | 8 | class TestAttachment(TestCase): 9 | c = Constants() 10 | 11 | def _deleteOrCreateTestRecord(self): 12 | client = ServiceNowClient(self.c.server, self.c.credentials) 13 | gr = client.GlideRecord('problem') 14 | gr.add_query('short_description', 'Unit Test - Attachments') 15 | gr.query() 16 | if gr.next(): 17 | return gr 18 | gr.initialize() 19 | gr.short_description = "Unit Test - Attachments" 20 | gr.description = "Second Field" 21 | gr.insert() 22 | client.session.close() 23 | return gr 24 | 25 | def _getOrCreateEmptyTestRecord(self): 26 | client = ServiceNowClient(self.c.server, self.c.credentials) 27 | gr = client.GlideRecord('problem') 28 | gr.add_query('short_description', 'Unit Test - Attachments - Empty') 29 | gr.query() 30 | if gr.next(): 31 | return gr 32 | gr.initialize() 33 | gr.short_description = "Unit Test - Attachments - Empty" 34 | gr.description = "Second Field" 35 | gr.insert() 36 | client.session.close() 37 | return gr 38 | 39 | 40 | def test_attachments_for(self): 41 | gr = self._getOrCreateEmptyTestRecord() 42 | attachments = gr.get_attachments() 43 | print(attachments) 44 | self.assertNotEqual(attachments, None) 45 | self.assertEqual(len(attachments), 0) 46 | 47 | def test_add_delete_get(self): 48 | client = ServiceNowClient(self.c.server, self.c.credentials) 49 | with TempTestRecord(client, 'problem') as gr: 50 | self.assertIsNotNone(gr.sys_id) 51 | attachments = gr.get_attachments() 52 | self.assertNotEqual(attachments, None) 53 | self.assertEqual(len(attachments), 0) 54 | 55 | content = "this is a sample attachment\nwith\nmulti\nlines" 56 | test_url = gr.add_attachment('test.txt', content) 57 | self.assertIsNotNone(test_url, "expected the location of test.txt") 58 | 59 | attachments = gr.get_attachments() 60 | self.assertEqual(len(attachments), 1) 61 | attachments.next() 62 | self.assertEqual(attachments.get_link(), test_url) 63 | 64 | test_txt_sys_id = attachments.sys_id 65 | 66 | bcontent = b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09' 67 | gr.add_attachment('test.bin', bcontent) 68 | 69 | attachments = gr.get_attachments() 70 | self.assertEqual(len(attachments), 2) 71 | 72 | tgr = client.GlideRecord(gr.table) 73 | assert tgr.get(gr.sys_id), "could not re-query the table?" 74 | self.assertEqual(len(tgr.get_attachments()), 2, "Could not see attachments on re-query?") 75 | 76 | for a in attachments: 77 | assert a.file_name.startswith('test'), f"expected a test file, not {a.file_name}" 78 | if a.file_name.endswith('txt'): 79 | self.assertEqual(a.file_name, 'test.txt') 80 | lines = a.readlines() 81 | print(lines) 82 | print(repr(lines)) 83 | self.assertEqual(lines[0], "this is a sample attachment") 84 | self.assertEqual(len(lines), 4) 85 | if a.file_name.endswith('bin'): 86 | self.assertEqual(a.file_name, 'test.bin') 87 | raw = a.read() 88 | self.assertEqual(raw, bcontent, "binary content did not match") 89 | 90 | # get 91 | problem_attachment = client.Attachment('problem') 92 | problem_attachment.get(test_txt_sys_id) 93 | self.assertEqual(problem_attachment.get_link(), test_url) 94 | self.assertEqual(problem_attachment.sys_id, test_txt_sys_id) 95 | self.assertEqual(problem_attachment.read().decode('ascii'), content) 96 | 97 | 98 | # list 99 | problem_attachment = client.Attachment('problem') 100 | problem_attachment.add_query('file_name', 'thisdoesntexist918') 101 | problem_attachment.query() 102 | self.assertFalse(problem_attachment.next()) 103 | 104 | 105 | problem_attachment = client.Attachment('problem') 106 | problem_attachment.add_query('file_name', 'test.txt') 107 | problem_attachment.query() 108 | self.assertTrue(problem_attachment.next()) 109 | self.assertEqual(problem_attachment.sys_id, test_txt_sys_id) 110 | 111 | 112 | client.session.close() 113 | -------------------------------------------------------------------------------- /test/test_snc_auth.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, skip 2 | 3 | from pysnc import ServiceNowClient 4 | from pysnc.auth import * 5 | from constants import Constants 6 | from pysnc import exceptions 7 | 8 | import requests 9 | import time 10 | 11 | class TestAuth(TestCase): 12 | c = Constants() 13 | 14 | def test_basic(self): 15 | client = ServiceNowClient(self.c.server, self.c.credentials) 16 | gr = client.GlideRecord('sys_user') 17 | gr.fields = 'sys_id' 18 | self.assertTrue(gr.get('6816f79cc0a8016401c5a33be04be441')) 19 | 20 | def test_basic_fail(self): 21 | client = ServiceNowClient(self.c.server, ('admin', 'this is not a real password')) 22 | try: 23 | gr = client.GlideRecord('sys_user') 24 | gr.get('does not matter') 25 | assert 'Exception should have been thrown' 26 | except exceptions.AuthenticationException as e: 27 | self.assertTrue('User Not Authenticated' in str(e)) 28 | self.assertTrue('Required to provide Auth information' in str(e)) 29 | except Exception: 30 | assert 'Should have got an Auth exception' 31 | 32 | @skip("Requires valid oauth client_id and secret, and I don't want to need anything not out of box") 33 | def test_oauth(self): 34 | # Manual setup using legacy oauth 35 | server = self.c.server 36 | creds = self.c.credentials 37 | 38 | client_id = self.c.get_value('CLIENT_ID') 39 | secret = self.c.get_value('CLIENT_SECRET') 40 | 41 | client = ServiceNowClient(self.c.server, ServiceNowPasswordGrantFlow(creds[0], creds[1], client_id, secret)) 42 | gr = client.GlideRecord('sys_user') 43 | gr.fields = 'sys_id' 44 | self.assertTrue(gr.get('6816f79cc0a8016401c5a33be04be441')) 45 | 46 | def test_auth_param_check(self): 47 | self.assertRaisesRegex(AuthenticationException, r'Cannot specify both.+', lambda: ServiceNowClient('anyinstance', auth='asdf', cert='asdf')) 48 | self.assertRaisesRegex(AuthenticationException, r'No valid auth.+', lambda: ServiceNowClient('anyinstance', auth='zzz')) 49 | 50 | def nop_test_jwt(self): 51 | """ 52 | we act as our own client here, which you should not do. 53 | """ 54 | 55 | # to test this we would 1st: get a JWT from a provider 56 | # jwt = getJwtFromOkta(user, pass) 57 | # then we would do something like this... 58 | ''' 59 | auth = ServiceNowJWTAuth(client_id, client_secret, jwt) 60 | client = ServiceNowClient(self.c.server, auth) 61 | 62 | gr = client.GlideRecord('sys_user') 63 | gr.fields = 'sys_id' 64 | assert gr.get('6816f79cc0a8016401c5a33be04be441'), "did not jwt auth" 65 | ''' 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /test/test_snc_batching.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from constants import Constants 5 | from pprint import pprint 6 | 7 | class TestBatching(TestCase): 8 | c = Constants() 9 | 10 | def test_batch_multi(self): 11 | client = ServiceNowClient(self.c.server, self.c.credentials) 12 | gr = client.GlideRecord('problem') 13 | gr.fields = 'sys_id' 14 | gr.batch_size = 3 15 | gr.limit = 9 16 | gr.query() 17 | 18 | res = [r.sys_id for r in gr] 19 | self.assertEqual(len(res), 9) 20 | client.session.close() 21 | 22 | def test_batch_multi_uneven(self): 23 | client = ServiceNowClient(self.c.server, self.c.credentials) 24 | gr = client.GlideRecord('problem') 25 | gr.fields = 'sys_id' 26 | gr.batch_size = 3 27 | gr.limit = 7 28 | gr.query() 29 | 30 | res = [r.sys_id for r in gr] 31 | self.assertEqual(len(res), 7) 32 | client.session.close() 33 | 34 | def test_batch_actual(self): 35 | client = ServiceNowClient(self.c.server, self.c.credentials) 36 | gr = client.GlideRecord('problem') 37 | gr.fields = 'sys_id' 38 | gr.batch_size = 3 39 | gr.query() 40 | gr.next() 41 | self.assertEqual(len(gr._GlideRecord__results), 3) 42 | client.session.close() 43 | 44 | def test_default_limit(self): 45 | client = ServiceNowClient(self.c.server, self.c.credentials) 46 | gr = client.GlideRecord('problem') 47 | gr.add_active_query() 48 | 49 | params = gr._parameters() 50 | print(params) 51 | self.assertEqual(params['sysparm_limit'], 100, "default batch size is not 100?") 52 | 53 | gr.limit = 400 54 | print(gr.limit) 55 | params = gr._parameters() 56 | print(params) 57 | self.assertTrue('sysparm_limit' in params) 58 | self.assertEqual(params['sysparm_limit'], 100, "batch size still 100 if we have a limit over batch size") 59 | client.session.close() 60 | 61 | def test_default_order(self): 62 | client = ServiceNowClient(self.c.server, self.c.credentials) 63 | gr = client.GlideRecord('problem') 64 | 65 | self.assertEqual(gr._parameters()['sysparm_query'], 'ORDERBYsys_id') 66 | gr.order_by('number') 67 | self.assertEqual(gr._parameters()['sysparm_query'], 'ORDERBYnumber') 68 | 69 | gr.order_by(None) 70 | self.assertEqual(gr._parameters()['sysparm_query'], 'ORDERBYsys_id') 71 | client.session.close() 72 | -------------------------------------------------------------------------------- /test/test_snc_element.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pysnc.record import GlideElement, GlideRecord 3 | import datetime, json, re 4 | 5 | 6 | class TestElement(TestCase): 7 | 8 | 9 | def test_truefalse(self): 10 | element = GlideElement('active', 'true') 11 | self.assertTrue(bool(element)) 12 | self.assertEqual(element, 'true') 13 | 14 | element = GlideElement('active', 'false') 15 | self.assertFalse(bool(element)) 16 | self.assertEqual(element, 'false') 17 | 18 | def test_iter(self): 19 | element = GlideElement('active', 'abcd') 20 | self.assertEqual(element, 'abcd') 21 | self.assertEqual(len(element), 4) 22 | for e in element: 23 | self.assertIsNotNone(e) 24 | 25 | for l, r in zip(element, 'abcd'): 26 | self.assertEqual(l, r) 27 | 28 | def test_str(self): 29 | # https://docs.python.org/3/library/stdtypes.html#string-methods 30 | real_value = "A String! \N{SPARKLES}" 31 | display_value = "****" 32 | element = GlideElement('name', real_value, display_value) 33 | 34 | self.assertEqual(element, real_value) 35 | 36 | # they dont throw, we good 37 | element.casefold() 38 | self.assertIsNotNone(element.encode('utf-8')) 39 | self.assertFalse(element.islower()) 40 | 41 | new_value = real_value + display_value 42 | self.assertIsNotNone(new_value) 43 | self.assertEqual(new_value, f"{real_value}{display_value}") 44 | 45 | def test_set_and_display(self): 46 | element = GlideElement('state', '3', 'Pending Change') 47 | self.assertEqual(element.get_display_value(), 'Pending Change') 48 | element.set_value('4') 49 | self.assertTrue(element.changes()) 50 | self.assertIsNone(element._display_value) 51 | self.assertEqual(element.get_display_value(), '4') 52 | self.assertEqual(repr(element), "GlideElement('4')") 53 | self.assertEqual(str(element), "4") 54 | 55 | def test_int(self): 56 | ten = GlideElement('num', 10) 57 | nten = GlideElement('negative_num', -10) 58 | 59 | print(GlideElement.__class__.__dict__) 60 | self.assertNotEqual(ten, 0) 61 | self.assertEqual(ten, 10) 62 | self.assertGreater(ten, 5) 63 | self.assertLess(ten, 15) 64 | self.assertGreaterEqual(ten, 10) 65 | self.assertLessEqual(ten, 10) 66 | self.assertTrue(ten) 67 | 68 | self.assertEqual(nten, -10) 69 | self.assertGreater(ten, nten) 70 | self.assertLess(nten, ten) 71 | 72 | self.assertEqual(ten + nten, 0) 73 | self.assertEqual(ten + 10, 20) 74 | self.assertEqual(ten - 10, 0) 75 | self.assertEqual(ten - nten, 20) 76 | 77 | def test_time(self): 78 | time = GlideElement('sys_created_on', '2007-07-03 18:48:47') 79 | # parse this into a known datetime 80 | same = datetime.datetime(2007, 7, 3, 18, 48, 47, tzinfo=datetime.timezone.utc) 81 | 82 | self.assertIsNotNone(time.date_value()) 83 | self.assertEqual(time.date_value(), same) 84 | time_in_ms = 1183488527000 85 | self.assertEqual(time.date_numeric_value(), time_in_ms) 86 | dt = datetime.datetime.fromtimestamp(time_in_ms/1000.0, tz=datetime.timezone.utc) 87 | self.assertEqual(same, dt) 88 | time.set_date_numeric_value(1183488528000) 89 | self.assertEqual(time, '2007-07-03 18:48:48') 90 | 91 | time2 = GlideElement('sys_created_on', '2008-07-03 18:48:47') 92 | 93 | self.assertGreater(time2.date_value(), time.date_value()) 94 | self.assertGreater(time2.date_numeric_value(), time.date_numeric_value()) 95 | 96 | def test_serialization(self): 97 | gr = GlideRecord(None, 'incident') 98 | gr.initialize() 99 | gr.test_field = 'some string' 100 | self.assertTrue(isinstance(gr.test_field, GlideElement)) 101 | r = gr.serialize() 102 | self.assertEqual(json.dumps(r), '{"test_field": "some string"}') 103 | 104 | gr.set_value('test_field', 'somevalue') 105 | gr.set_display_value('test_field', 'Some Value') 106 | print(gr.serialize()) 107 | self.assertEqual(json.dumps(gr.serialize()), '{"test_field": "somevalue"}') 108 | self.assertEqual(json.dumps(gr.serialize(display_value=True)), '{"test_field": "Some Value"}') 109 | self.assertEqual(json.dumps(gr.serialize(display_value='both')), '{"test_field": {"value": "somevalue", "display_value": "Some Value"}}') 110 | 111 | # but what if we set a element to an element 112 | gr2 = GlideRecord(None, 'incident') 113 | gr2.initialize() 114 | gr2.two_field = gr.test_field 115 | self.assertEqual(json.dumps(gr2.serialize()), '{"two_field": "somevalue"}') 116 | self.assertEqual(json.dumps(gr2.serialize(display_value=True)), '{"two_field": "Some Value"}') 117 | self.assertEqual(gr2.two_field.get_name(), 'two_field') 118 | 119 | def test_serialization_with_link(self): 120 | gr = GlideRecord(None, 'incident') 121 | gr.initialize() 122 | gr.test_field = 'some string' 123 | self.assertTrue(isinstance(gr.test_field, GlideElement)) 124 | r = gr.serialize() 125 | self.assertEqual(json.dumps(r), '{"test_field": "some string"}') 126 | 127 | gr.set_value('test_field', 'somevalue') 128 | gr.set_display_value('test_field', 'Some Value') 129 | gr.set_link('test_field', 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') 130 | print(gr.serialize()) 131 | self.assertEqual(json.dumps(gr.serialize()), '{"test_field": "somevalue"}') 132 | self.assertEqual(json.dumps(gr.serialize(display_value=True, exclude_reference_link=False)), '{"test_field": {"display_value": "Some Value", "link": "https://dev00000.service-now.com/api/now/table/sys___/abcde12345"}}') 133 | self.assertEqual(json.dumps(gr.serialize(display_value=False, exclude_reference_link=False)), '{"test_field": {"value": "somevalue", "link": "https://dev00000.service-now.com/api/now/table/sys___/abcde12345"}}') 134 | self.assertEqual(json.dumps(gr.serialize(display_value='both', exclude_reference_link=False)), '{"test_field": {"value": "somevalue", "display_value": "Some Value", "link": "https://dev00000.service-now.com/api/now/table/sys___/abcde12345"}}') 135 | 136 | def test_set_element(self): 137 | element = GlideElement('state', '3', 'Pending Change') 138 | 139 | gr = GlideRecord(None, 'incident') 140 | gr.initialize() 141 | gr.state = '3' 142 | out1 = gr.serialize() 143 | print(out1) 144 | gr.initialize() 145 | gr.state = element 146 | out2 = gr.serialize() 147 | print(out2) 148 | self.assertEqual(out1, out2) 149 | 150 | def test_hashing(self): 151 | element = GlideElement('state', '3', 'Pending Change') 152 | my_dict = {} 153 | my_dict[element] = 1 154 | self.assertEqual(my_dict[element], 1) 155 | 156 | def test_int(self): 157 | element = GlideElement('state', '3', 'Pending Change') 158 | result = int(element) 159 | self.assertEqual(result, 3) 160 | 161 | def test_float(self): 162 | element = GlideElement('state', '3.1', 'Pending Change') 163 | result = float(element) 164 | self.assertEqual(result, 3.1) 165 | 166 | def test_complex(self): 167 | element = GlideElement('state', '5-9j', 'Pending Change') 168 | result = complex(element) 169 | self.assertEqual(result, 5-9j) 170 | 171 | def test_indexing(self): 172 | element = GlideElement('state', 'approved') 173 | self.assertEqual(element[0], 'a') 174 | self.assertEqual(element[-1], 'd') 175 | self.assertEqual(element[::-1], 'devorppa') 176 | 177 | def test_string_methods(self): 178 | element = GlideElement('state', 'approved Value') 179 | self.assertEqual(element.capitalize(), 'Approved value') 180 | self.assertEqual(element.casefold(), 'approved value') 181 | self.assertEqual(element.encode(), b'approved Value') 182 | self.assertEqual(element.isdigit(), False) 183 | self.assertEqual(element.islower(), False) 184 | self.assertEqual(element.isprintable(), True) 185 | 186 | def test_regex(self): 187 | from collections import UserString 188 | element = GlideElement('state', '3', 'Pending Change') 189 | result = re.match(r"\d", element) 190 | self.assertIsNotNone(result) 191 | self.assertEqual(result.start(), 0) 192 | self.assertEqual(result.end(), 1) 193 | self.assertEqual(result.group(), '3') 194 | 195 | element = GlideElement('state', 'ohh 3 okay', 'Pending Change') 196 | result = re.search(r"(\d)", element) 197 | self.assertIsNotNone(result) 198 | self.assertEqual(result.start(), 4) 199 | self.assertEqual(result.end(), 5) 200 | self.assertEqual(result.group(), '3') 201 | 202 | def test_parent(self): 203 | class MockRecord: 204 | def get_element(self, name): 205 | rn = name.split('.')[-1] 206 | return GlideElement(rn, 'test@test.test', None, self) 207 | opened_by = GlideElement('opened_by', 'somesysid', 'User Name', MockRecord()) 208 | 209 | self.assertEqual(opened_by, 'somesysid') 210 | ele = opened_by.email 211 | self.assertEqual(ele, 'test@test.test') 212 | self.assertEqual(ele.get_name(), 'email') 213 | self.assertTrue(isinstance(ele._parent_record, MockRecord)) 214 | 215 | def test_changes(self): 216 | element = GlideElement('state', '3', 'Pending Change') 217 | self.assertFalse(element.changes()) 218 | element.set_value('4') 219 | self.assertTrue(element.changes()) 220 | -------------------------------------------------------------------------------- /test/test_snc_iteration.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from constants import Constants 5 | from pprint import pprint 6 | 7 | class TestIteration(TestCase): 8 | c = Constants() 9 | 10 | def test_default_behavior(self): 11 | client = ServiceNowClient(self.c.server, self.c.credentials) 12 | gr = client.GlideRecord('sys_metadata', batch_size=100) 13 | gr.fields = 'sys_id' 14 | gr.limit = 500 15 | gr.query() 16 | self.assertTrue(gr._is_rewindable()) 17 | 18 | self.assertTrue(len(gr) > 500, 'Expected more than 500 records') 19 | 20 | count = 0 21 | while gr.next(): 22 | count += 1 23 | self.assertEqual(count, 500, 'Expected 500 records when using next') 24 | 25 | self.assertEqual(len([r.sys_id for r in gr]), 500, 'Expected 500 records when an iterable') 26 | self.assertEqual(len([r.sys_id for r in gr]), 500, 'Expected 500 records when iterated again') 27 | 28 | # expect the same for next 29 | count = 0 30 | while gr.next(): 31 | count += 1 32 | self.assertEqual(count, 0, 'Expected 0 records when not rewound, as next does not auto-rewind') 33 | gr.rewind() 34 | while gr.next(): 35 | count += 1 36 | self.assertEqual(count, 500, 'Expected 500 post rewind') 37 | 38 | # should not throw 39 | gr.query() 40 | gr.query() 41 | 42 | client.session.close() 43 | 44 | def test_rewind_behavior(self): 45 | client = ServiceNowClient(self.c.server, self.c.credentials) 46 | gr = client.GlideRecord('sys_metadata', batch_size=250, rewindable=False) 47 | gr.fields = 'sys_id' 48 | gr.limit = 500 49 | gr.query() 50 | self.assertEqual(gr._GlideRecord__current, -1) 51 | self.assertFalse(gr._is_rewindable()) 52 | self.assertEqual(len([r for r in gr]), 500, 'Expected 500 records when an iterable') 53 | self.assertEqual(len([r for r in gr]), 0, 'Expected no records when iterated again') 54 | 55 | # but if we query again... 56 | with self.assertRaises(RuntimeError): 57 | gr.query() 58 | -------------------------------------------------------------------------------- /test/test_snc_serialization.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pysnc import ServiceNowClient 4 | from constants import Constants 5 | from pprint import pprint 6 | 7 | class TestSerialization(TestCase): 8 | c = Constants() 9 | 10 | def test_pandas_smart(self): 11 | client = ServiceNowClient(self.c.server, self.c.credentials) 12 | gr = client.GlideRecord('problem') 13 | gr.fields = 'sys_id,short_description,state' 14 | 15 | gr.limit = 4 16 | gr.query() 17 | 18 | print(gr.serialize_all(display_value='both')) 19 | 20 | data = gr.to_pandas() 21 | self.assertIsInstance(data, dict) 22 | self.assertTrue('sys_id' in data) 23 | self.assertTrue('short_description' in data) 24 | self.assertTrue('state__value' in data) 25 | self.assertTrue('state__display' in data) 26 | self.assertEqual(len(data['sys_id']), 4) 27 | self.assertEqual(len(data['short_description']), 4) 28 | self.assertEqual(len(data['state__value']), 4) 29 | self.assertEqual(len(data['state__display']), 4) 30 | client.session.close() 31 | 32 | def test_pandas_both(self): 33 | client = ServiceNowClient(self.c.server, self.c.credentials) 34 | gr = client.GlideRecord('problem') 35 | gr.fields = 'sys_id,short_description,state' 36 | 37 | gr.limit = 4 38 | gr.query() 39 | 40 | print(gr.serialize_all(display_value='both')) 41 | 42 | data = gr.to_pandas(mode='both') 43 | print(data) 44 | self.assertIsInstance(data, dict) 45 | self.assertTrue('sys_id__value' in data) 46 | self.assertTrue('short_description__display' in data) 47 | self.assertTrue('state__value' in data) 48 | self.assertTrue('state__display' in data) 49 | self.assertEqual(len(data['sys_id__value']), 4) 50 | self.assertEqual(len(data['short_description__display']), 4) 51 | self.assertEqual(len(data['state__value']), 4) 52 | self.assertEqual(len(data['state__display']), 4) 53 | client.session.close() 54 | 55 | def test_pandas_value(self): 56 | client = ServiceNowClient(self.c.server, self.c.credentials) 57 | gr = client.GlideRecord('problem') 58 | gr.fields = 'sys_id,short_description,state' 59 | 60 | gr.limit = 4 61 | gr.query() 62 | 63 | print(gr.serialize_all(display_value='both')) 64 | 65 | data = gr.to_pandas(mode='value') 66 | print(data) 67 | self.assertIsInstance(data, dict) 68 | self.assertTrue('sys_id' in data) 69 | self.assertTrue('short_description' in data) 70 | self.assertTrue('state' in data) 71 | self.assertFalse('state__value' in data) 72 | self.assertEqual(len(data['sys_id']), 4) 73 | client.session.close() 74 | 75 | def test_pandas_order_cols(self): 76 | client = ServiceNowClient(self.c.server, self.c.credentials) 77 | gr = client.GlideRecord('problem') 78 | gr.fields = 'sys_id,short_description,state' 79 | 80 | gr.limit = 4 81 | gr.query() 82 | 83 | print(gr.serialize_all(display_value='both')) 84 | 85 | data = gr.to_pandas() 86 | print(data) 87 | self.assertListEqual(list(data.keys()), ['sys_id', 'short_description', 'state__value', 'state__display']) 88 | data = gr.to_pandas(mode='display') 89 | print(data) 90 | self.assertListEqual(list(data.keys()), ['sys_id', 'short_description', 'state']) 91 | data = gr.to_pandas(columns=['jack', 'jill', 'hill'], mode='display') 92 | print(data) 93 | self.assertListEqual(list(data.keys()), ['jack', 'jill', 'hill']) 94 | client.session.close() 95 | 96 | 97 | def test_serialize_all_batch(self): 98 | client = ServiceNowClient(self.c.server, self.c.credentials) 99 | gr = client.GlideRecord('problem') 100 | gr.batch_size = 3 101 | gr.limit = 9 102 | gr.query() 103 | 104 | records = gr.serialize_all() 105 | self.assertEqual(len(records), 9) 106 | client.session.close() 107 | 108 | def test_serialize_noncurrent(self): 109 | client = ServiceNowClient(self.c.server, self.c.credentials) 110 | gr = client.GlideRecord('problem') 111 | gr.fields = 'sys_id,short_description,state' 112 | gr.limit = 4 113 | gr.query() 114 | self.assertIsNone(gr.serialize()) 115 | gr.next() 116 | self.assertIsNotNone(gr.serialize()) 117 | client.session.close() 118 | 119 | def test_serialize_changes(self): 120 | client = ServiceNowClient(self.c.server, self.c.credentials) 121 | gr = client.GlideRecord('problem') 122 | gr.fields = 'sys_id,short_description,state' 123 | gr.limit = 4 124 | gr.query() 125 | gr.next() 126 | data = gr.serialize() 127 | self.assertIsNotNone(data) 128 | self.assertListEqual(list(data.keys()), ['sys_id', 'short_description', 'state']) 129 | self.assertListEqual(list(gr.serialize(changes_only=True).keys()), []) 130 | gr.short_description = 'new' 131 | self.assertListEqual(list(gr.serialize(changes_only=True).keys()), ['short_description']) 132 | client.session.close() 133 | 134 | def test_serialize(self): 135 | client = ServiceNowClient(self.c.server, self.c.credentials) 136 | gr = client.GlideRecord('some_table') 137 | gr.initialize() 138 | gr.strfield = 'my string' 139 | gr.set_display_value('strfield', 'my string display value') 140 | gr.intfield = 5 141 | data = gr.serialize() 142 | self.assertIsNotNone(data) 143 | self.assertEqual(data, {'intfield': 5, 'strfield': 'my string'}) 144 | client.session.close() 145 | 146 | def test_serialize_display(self): 147 | client = ServiceNowClient(self.c.server, self.c.credentials) 148 | gr = client.GlideRecord('some_table') 149 | gr.initialize() 150 | gr.strfield = 'my string' 151 | gr.set_display_value('strfield', 'my string display value') 152 | gr.intfield = 5 153 | data = gr.serialize(display_value=True) 154 | self.assertIsNotNone(data) 155 | self.assertEqual(gr.get_value('strfield'), 'my string') 156 | self.assertEqual(gr.get_display_value('strfield'), 'my string display value') 157 | self.assertEqual(gr.serialize(), {'intfield': 5, 'strfield': 'my string'}) 158 | self.assertEqual(data, {'intfield': 5, 'strfield': 'my string display value'}) 159 | client.session.close() 160 | 161 | def test_serialize_reference_link(self): 162 | client = ServiceNowClient(self.c.server, self.c.credentials) 163 | gr = client.GlideRecord('some_table') 164 | gr.initialize() 165 | gr.reffield = 'my reference' 166 | gr.set_link('reffield', 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') 167 | 168 | data = gr.serialize(exclude_reference_link=False) 169 | self.assertIsNotNone(data) 170 | self.assertEqual(gr.get_value('reffield'), 'my reference') 171 | self.assertTrue(gr.get_link(True).endswith('/some_table.do?sys_id=-1'), f"was {gr.get_link()}") 172 | self.assertEqual(gr.reffield.get_link(), 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') 173 | self.assertEqual(gr.serialize(exclude_reference_link=False), {'reffield':{'value': 'my reference','link':'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}}) 174 | self.assertEqual(data, {'reffield':{'value': 'my reference','link':'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}}) 175 | 176 | gr.reffield.set_link('https://dev00000.service-now.com/api/now/table/sys___/xyz789') 177 | self.assertEqual(gr.reffield.get_link(), 'https://dev00000.service-now.com/api/now/table/sys___/xyz789') 178 | client.session.close() 179 | 180 | def test_serialize_reference_link_all(self): 181 | client = ServiceNowClient(self.c.server, self.c.credentials) 182 | gr = client.GlideRecord('some_table') 183 | gr.initialize() 184 | gr.reffield = 'my reference' 185 | gr.set_link('reffield', 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') 186 | gr.set_display_value('reffield', 'my reference display') 187 | 188 | self.assertEqual(gr.get_value('reffield'), 'my reference') 189 | self.assertEqual(gr.get_display_value('reffield'), 'my reference display') 190 | self.assertEqual(gr.reffield.get_link(), 'https://dev00000.service-now.com/api/now/table/sys___/abcde12345') 191 | 192 | self.assertEqual(gr.serialize(), {'reffield':'my reference'}) 193 | self.assertEqual( 194 | gr.serialize(exclude_reference_link=False), 195 | {'reffield':{'value': 'my reference','link':'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} 196 | ) 197 | self.assertEqual( 198 | gr.serialize(display_value=True, exclude_reference_link=False), 199 | {'reffield':{'display_value': 'my reference display', 'link':'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} 200 | ) 201 | self.assertEqual( 202 | gr.serialize(display_value='both', exclude_reference_link=False), 203 | {'reffield':{'value': 'my reference','display_value': 'my reference display', 'link':'https://dev00000.service-now.com/api/now/table/sys___/abcde12345'}} 204 | ) 205 | client.session.close() 206 | 207 | def test_str(self): 208 | client = ServiceNowClient(self.c.server, self.c.credentials) 209 | gr = client.GlideRecord('some_table') 210 | gr.initialize() 211 | gr.strfield = 'my string' 212 | gr.set_display_value('strfield', 'my string display value') 213 | gr.intfield = 5 214 | data = str(gr) 215 | self.assertIsNotNone(data) 216 | # dict is unordered, so do some contains checks 217 | self.assertTrue(data.startswith('some_table')) 218 | self.assertTrue('my string' in data) 219 | self.assertTrue('intfield' in data) 220 | client.session.close() 221 | 222 | def test_serialize_all(self): 223 | client = ServiceNowClient(self.c.server, self.c.credentials) 224 | gr = client.GlideRecord('problem') 225 | gr.fields = 'sys_id,short_description,state' 226 | gr.limit = 4 227 | gr.query() 228 | data = gr.serialize_all() 229 | self.assertEqual(len(data), 4) 230 | for prb in data: 231 | self.assertEqual(list(prb.keys()), ['sys_id', 'short_description', 'state']) 232 | 233 | # we do *not* expect the link if we are value-only 234 | data = gr.serialize_all(exclude_reference_link=False) 235 | self.assertEqual(type(data[0]['short_description']), str) 236 | 237 | # TODO: test this 238 | #data = gr.serialize_all(display_value='both', exclude_reference_link=False) 239 | #self.assertEqual(type(data[0]['short_description']), dict) 240 | #print(data[0]['short_description']) 241 | #self.assertTrue('value' in data[0]['short_description']['link']) 242 | client.session.close() 243 | 244 | 245 | -------------------------------------------------------------------------------- /test/utils.py: -------------------------------------------------------------------------------- 1 | class TempTestRecord: 2 | def __init__(self, client, table, default_data=None): 3 | self.__gr = client.GlideRecord(table) 4 | self.__data = default_data 5 | 6 | def __enter__(self): 7 | self.__gr.initialize() 8 | if self.__data: 9 | for k in self.__data.keys(): 10 | self.__gr.set_value(k, self.__data[k]) 11 | self.__gr.insert() 12 | return self.__gr 13 | 14 | def __exit__(self, exc_type, exc_val, exc_tb): 15 | self.__gr.delete() 16 | -------------------------------------------------------------------------------- /whitelist.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | { 4 | "id": "CVE-2018-20225", 5 | "reason": "get build passing, Disputed: pip --extra-index-url Improper Input Validation" 6 | } 7 | ] 8 | } 9 | --------------------------------------------------------------------------------