├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── feedback.md ├── dependabot.yml └── workflows │ ├── main_pr_tests.yaml │ ├── publish_docs.yaml │ └── publish_pypi.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── _ext │ └── apioptions.py ├── _static │ ├── api-keychain.png │ └── user-keychain.png ├── conf.py ├── contributors │ ├── index.rst │ └── playground.rst ├── index.rst ├── reference │ ├── clients.rst │ ├── clients_classic.rst │ ├── clients_jcds2.rst │ ├── clients_pro.rst │ ├── clients_webhooks.rst │ ├── credentials.rst │ ├── helpers.rst │ ├── index.rst │ ├── models_classic.rst │ ├── models_pro.rst │ ├── models_webhooks.rst │ └── webhook_generators.rst ├── user │ ├── advanced.rst │ ├── classic_api.rst │ ├── getting_started.rst │ ├── index.rst │ ├── jcds2.rst │ └── pro_api.rst └── webhooks │ ├── index.rst │ ├── webhook_models.rst │ ├── webhook_receiver.rst │ └── webhooks_client.rst ├── pyproject.toml ├── src └── jamf_pro_sdk │ ├── __about__.py │ ├── __init__.py │ ├── clients │ ├── __init__.py │ ├── auth.py │ ├── classic_api.py │ ├── jcds2.py │ ├── pro_api │ │ ├── __init__.py │ │ └── pagination.py │ └── webhooks.py │ ├── exceptions.py │ ├── helpers.py │ ├── models │ ├── __init__.py │ ├── classic │ │ ├── __init__.py │ │ ├── advanced_computer_searches.py │ │ ├── categories.py │ │ ├── computer_groups.py │ │ ├── computers.py │ │ ├── criteria.py │ │ ├── network_segments.py │ │ └── packages.py │ ├── client.py │ ├── pro │ │ ├── __init__.py │ │ ├── api_options.py │ │ ├── computers.py │ │ ├── jcds2.py │ │ ├── mdm.py │ │ ├── mobile_devices.py │ │ └── packages.py │ └── webhooks │ │ ├── __init__.py │ │ └── webhooks.py │ └── py.typed └── tests ├── __init__.py ├── integration ├── __init__.py ├── conftest.py ├── test_classic_client_computers.py ├── test_client_auth.py ├── test_pro_client_computers.py ├── test_pro_client_mobile_devices.py └── test_pro_client_packages.py └── unit ├── __init__.py ├── conftest.py ├── models ├── __init__.py ├── test_models_classic_categories.py ├── test_models_classic_computer_groups.py ├── test_models_classic_computers.py ├── test_models_classic_network_segments.py └── test_models_classic_utils.py ├── test_pro_api_expressions.py ├── test_webhooks_faker.py └── utils.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Dependabot 2 | .github/workflows/* @nstrauss @liquidz00 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug] Issue title" 5 | labels: ["bug"] 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ### Steps to Reproduce 13 | 14 | 15 | 16 | ```python 17 | import jamf_pro_sdk 18 | 19 | ``` 20 | 21 | ### Expected Result 22 | 23 | 24 | 25 | ### Actual Result 26 | 27 | 28 | 29 | ### System Information 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request] Issue title" 5 | labels: ["enhancement"] 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ### Proposal 13 | 14 | 15 | 16 | ### Additional Details 17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feedback 3 | about: Suggest a change or improvement to existing features 4 | title: "[Feedback] Issue title" 5 | labels: ["feedback"] 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ### Current 13 | 14 | 15 | 16 | ```python 17 | import jamf_pro_sdk 18 | 19 | ``` 20 | 21 | ### Proposed 22 | 23 | 24 | 25 | ```python 26 | import jamf_pro_sdk 27 | 28 | ``` 29 | 30 | ### System Information 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/main_pr_tests.yaml: -------------------------------------------------------------------------------- 1 | name: Main Pull Request Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | paths-ignore: 8 | - '.github/**' 9 | - 'docs/**' 10 | - '**.md' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | setup-env: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | 22 | - name: Setup Python 23 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 24 | with: 25 | python-version: '3.9' 26 | cache: 'pip' 27 | 28 | - name: Install 29 | run: make install 30 | 31 | - name: Lint Checker 32 | run: make lint 33 | 34 | - name: Tests 35 | run: make test 36 | 37 | # exp-integration-tests: 38 | # continue-on-error: true 39 | # runs-on: ubuntu-latest 40 | # steps: 41 | # - name: Checkout 42 | # uses: actions/checkout@v3 43 | 44 | # - name: Setup Python 45 | # uses: actions/setup-python@v4 46 | # with: 47 | # python-version: '3.9' 48 | # cache: 'pip' 49 | 50 | # - name: Install 51 | # run: make install 52 | 53 | # - name: Run Integration Tests 54 | # run: make test-all 55 | # env: 56 | # JAMF_PRO_HOST: ${{ vars.JAMF_PRO_HOST }} 57 | # JAMF_PRO_CLIENT_ID: ${{ vars.JAMF_PRO_CLIENT_ID }} 58 | # JAMF_PRO_CLIENT_SECRET: ${{ vars.JAMF_PRO_CLIENT_SECRET }} 59 | -------------------------------------------------------------------------------- /.github/workflows/publish_docs.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | paths: 8 | - 'docs/**' 9 | - 'src/**' 10 | workflow_dispatch: 11 | inputs: 12 | ref: 13 | description: The branch, tag, or commit SHA1 to build the docs from. 14 | 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | 26 | build: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | ref: ${{ inputs.ref }} 36 | 37 | - name: Setup Python 38 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 39 | with: 40 | python-version: '3.9' 41 | 42 | - name: Build Sphinx Docs 43 | run: | 44 | make install 45 | make docs 46 | 47 | - name: Upload Artifact 48 | uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 49 | with: 50 | path: 'build/docs/' 51 | 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 55 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Release to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: The branch, tag, or commit SHA1 to build the release from. 8 | 9 | jobs: 10 | 11 | pypi-publish: 12 | name: Upload a release to PyPI 13 | runs-on: ubuntu-latest 14 | environment: pypi-release 15 | permissions: 16 | contents: read 17 | id-token: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | ref: ${{ inputs.ref }} 23 | 24 | - name: Setup Python 25 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 26 | with: 27 | python-version: '3.9' 28 | 29 | - name: Install pypa/build 30 | run: python3 -m pip install -U build --user 31 | 32 | - name: Build Package 33 | run: make build 34 | 35 | - name: Publish package distributions to PyPI 36 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *__pycache__/ 2 | *.egg-info/ 3 | .idea/ 4 | .pytest_cache/ 5 | .ruff_cache/ 6 | .venv/ 7 | build/ 8 | coverage/ 9 | dist/ 10 | docs/contributors/_autosummary/ 11 | docs/reference/_autosummary/ 12 | htmlcov/ 13 | .coverage 14 | .DS_Store 15 | .vscode 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [0.8a1] - 2025-05-19 11 | 12 | This version includes **breaking changes** for credential providers. `BasicAuthCredentialProvider` has been deprecated and removed. 13 | Please migrate to `ApiClientCredentialsProvider` if you haven't done so already. [Client credentials](https://developer.jamf.com/jamf-pro/docs/client-credentials) with API roles and clients is the recommended path forward. 14 | Where basic auth is still required with username/password use `UserCredentialsProvider`. 15 | 16 | ### Changed 17 | 18 | - Update sort and filter fields for device inventory endpoints. 19 | - Change default credential provider to `ApiClientCredentialsProvider` in docs. 20 | - Refactored `LoadFromAWSSecretsManager`, `PromptForCredentials` and `LoadFromKeychain` into helper functions instead of classes. Each function returns a `CredentialProvider` type that is specified or raises a `TypeError` if an invalid credential provider was passed. 21 | 22 | ### Fixed 23 | 24 | - GitHub Actions workflows now pin action versions to commit hash. Docs publishing was broken due to outdated actions. 25 | 26 | ### PRs Included 27 | 28 | - [#57](https://github.com/macadmins/jamf-pro-sdk-python/pull/57) 29 | - [#60](https://github.com/macadmins/jamf-pro-sdk-python/pull/60) 30 | - [#62](https://github.com/macadmins/jamf-pro-sdk-python/pull/62) 31 | 32 | ## [0.7a1] - 2024-12-03 33 | 34 | Special shoutout to [macserv](https://github.com/macserv) for this contribution to the project! 35 | 36 | ### Added 37 | 38 | - Pro API `get_packages_v1()` 39 | 40 | ### Changed 41 | 42 | - Overload interfaces for Pro API methods that have multiple return types (this will now be a standard going forward). 43 | - Added `files` argument for `pro_api_request()` to pass through to `requests` for `POST` requests. 44 | 45 | ### Fixed 46 | 47 | - Various Python typing enhancements. 48 | 49 | ### PRs Included 50 | 51 | - [#54](https://github.com/macadmins/jamf-pro-sdk-python/pull/54) 52 | 53 | ## [0.6a2] - 2024-07-24 54 | 55 | ### Changed 56 | 57 | - Fixed missing criteria options for Classic API advanced searches and groups. 58 | - Fixed sections not being passed when calling `get_mobile_device_inventory_v2()`. 59 | - Fix malformed XML when generating computer group data from a model. 60 | - Removed `black` from dev tools. 61 | 62 | ### PRs Included 63 | 64 | - [#42](https://github.com/macadmins/jamf-pro-sdk-python/pull/42) 65 | - [#45](https://github.com/macadmins/jamf-pro-sdk-python/pull/45) 66 | - [#48](https://github.com/macadmins/jamf-pro-sdk-python/pull/48) 67 | - [#49](https://github.com/macadmins/jamf-pro-sdk-python/pull/49) 68 | - [#50](https://github.com/macadmins/jamf-pro-sdk-python/pull/50) 69 | 70 | ## [0.6a1] - 2024-02-13 71 | 72 | ### Added 73 | 74 | - Pro API `get_mobile_device_inventory_v2()` 75 | 76 | ### Changed 77 | 78 | - Added `end_page` argument to `get_mdm_commands_v2()` 79 | 80 | ### PRs Included 81 | 82 | - [#39](https://github.com/macadmins/jamf-pro-sdk-python/pull/39) 83 | 84 | ## [0.5a2] - 2024-01-09 85 | 86 | ### Fixed 87 | 88 | - V1Site model optional values did not have default of `None`. 89 | 90 | ## [0.5a1] - 2024-01-04 91 | 92 | ### Added 93 | 94 | - Classic API `update_category_by_id()` 95 | - Classic API `delete_category_by_id()` 96 | - Classic API `create_category()` 97 | 98 | ### Changed 99 | 100 | - Pydantic V2 Update 101 | 102 | ### Fixed 103 | 104 | - Pagination bug with Pro API paginator. 105 | 106 | ### PRs Included 107 | 108 | - [#26](https://github.com/macadmins/jamf-pro-sdk-python/pull/26) 109 | - [#36](https://github.com/macadmins/jamf-pro-sdk-python/pull/36) 110 | 111 | ## [0.4a1] - 2023-10-25 112 | 113 | ### Added 114 | 115 | - Classic API `create_advanced_computer_search()` 116 | - Classic API `list_all_advanced_computer_searches()` 117 | - Classic API `get_advanced_computer_search_by_id()` 118 | - Classic API `update_advanced_computer_search_by_id()` 119 | - Classic API `delete_advanced_computer_search_by_id()` 120 | - Classic API `list_all_categories()` 121 | - Classic API `get_category_by_id()` 122 | - Classic API `set_computer_unmanaged_by_id()` 123 | - Classic API `set_computer_managed_by_id()` 124 | 125 | ### PRs Included 126 | 127 | - [#15](https://github.com/macadmins/jamf-pro-sdk-python/pull/15) 128 | - [#20](https://github.com/macadmins/jamf-pro-sdk-python/pull/20) 129 | - [#21](https://github.com/macadmins/jamf-pro-sdk-python/pull/21) 130 | - [#22](https://github.com/macadmins/jamf-pro-sdk-python/pull/22) 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mac Admins Open Source 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | .PHONY: docs 3 | 4 | install: 5 | python3 -m pip install --upgrade --force-reinstall --editable '.[dev]' 6 | 7 | uninstall: 8 | python3 -m pip uninstall -y -r <(python3 -m pip freeze) 9 | 10 | clean: 11 | rm -rf build/ dist/ src/*.egg-info **/__pycache__ .coverage .pytest_cache/ .ruff_cache/ 12 | 13 | test: 14 | pytest tests/unit 15 | 16 | test-all: 17 | pytest tests 18 | 19 | lint: 20 | ruff format --check src tests 21 | ruff check src tests 22 | 23 | format: 24 | ruff format src tests 25 | ruff check --select I001 --fix src tests # Only fixes import order 26 | 27 | build: 28 | python3 -m build --sdist --wheel 29 | 30 | docs: 31 | rm -f docs/reference/_autosummary/*.rst 32 | sphinx-build -b html docs/ build/docs/ 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jamf Pro SDK for Python 2 | 3 | A client library for the Jamf Pro APIs and webhooks. 4 | 5 | ```python 6 | from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 7 | 8 | client = JamfProClient( 9 | server="dummy.jamfcloud.com", 10 | credentials=ApiClientCredentialsProvider("client_id", "client_secret") 11 | ) 12 | 13 | all_computers = client.pro_api.get_computer_inventory_v1() 14 | ``` 15 | 16 | Read the full documentation on [GitHub Pages](https://macadmins.github.io/jamf-pro-sdk-python/). 17 | 18 | ## Installing 19 | 20 | Install releases from PyPI: 21 | 22 | ```console 23 | % python -m pip install jamf-pro-sdk 24 | ``` 25 | 26 | You may also install directly from GitHub if you are testing in-development features and/or changes: 27 | 28 | ```console 29 | % pip install git+https://github.com/macadmins/jamf-pro-sdk-python.git@ 30 | ``` 31 | 32 | The Jamf Pro SDK supports Python 3.9+. 33 | 34 | # Bugs, Feedback, and Feature Requests 35 | 36 | The Jamf Pro SDK for Python is currently in alpha. Not all APIs are available as methods through the clients, and some functionality may change during the alpha based on community feedback. 37 | 38 | If you encounter a bug, or undesired behavior, please open a [Bug report issue](https://github.com/macadmins/jamf-pro-sdk-python/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title=%5BBug%5D+Issue+title). 39 | 40 | If you want to request or propose a change to behavior in the SDK during the alpha please oen a [Feedback issue](https://github.com/macadmins/jamf-pro-sdk-python/issues/new?assignees=&labels=feedback&projects=&template=feedback.md&title=%5BFeedback%5D+Issue+title). Feedback issues are in-between a bug report and a feature request. You are describing a current implementation (or lack thereof) and the desired change. Feedback issues are used for vetting contributions with project maintainers and the community before work begins. 41 | 42 | If there is a feature or API you would like added (or prioritized) to the SDK please open a [Feature request issue](https://github.com/macadmins/jamf-pro-sdk-python/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=%5BFeature+Request%5D+Issue+title). With feature requests include a detailed description and a code example that shows how you envision the feature being used. 43 | 44 | > For all issue templates be sure to fill out every section! 45 | 46 | # Contributing 47 | 48 | There are many ways to directly contribute to the project. You can enhance the documentation and user guides, or add additional API models and methods. For both there are guidelines for how to proceed. 49 | 50 | Visit the [Contributors](https://macadmins.github.io/jamf-pro-sdk-python/contributors/index.html) section of the documentation for more details. 51 | -------------------------------------------------------------------------------- /docs/_ext/apioptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from docutils.statemachine import StringList 6 | from sphinx.ext.autodoc import ClassDocumenter 7 | 8 | 9 | class ApiOptionsDocumenter(ClassDocumenter): 10 | objtype = "apioptions" 11 | directivetype = "attribute" 12 | member_order = 60 13 | 14 | # must be higher than ClassDocumenter 15 | priority = 16 16 | 17 | @classmethod 18 | def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any) -> bool: 19 | try: 20 | return isinstance(member, list) 21 | except TypeError: 22 | return False 23 | 24 | def add_directive_header(self, sig): 25 | pass 26 | 27 | def add_content(self, more_content: StringList | None) -> None: 28 | self.add_line(", ".join([f"``{i}``" for i in self.object]), self.get_sourcename()) 29 | 30 | 31 | def setup(app): 32 | app.setup_extension("sphinx.ext.autodoc") 33 | app.add_autodocumenter(ApiOptionsDocumenter) 34 | -------------------------------------------------------------------------------- /docs/_static/api-keychain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/docs/_static/api-keychain.png -------------------------------------------------------------------------------- /docs/_static/user-keychain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/docs/_static/user-keychain.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | import os 9 | import sys 10 | 11 | sys.path.append(os.path.abspath("./_ext")) 12 | 13 | sys.path.insert(0, os.path.abspath("..")) 14 | 15 | from src.jamf_pro_sdk.__about__ import __version__ 16 | 17 | project = "Jamf Pro SDK for Python" 18 | author = "Bryson Tyrrell" 19 | 20 | version = __version__ 21 | release = f" v{__version__}" 22 | 23 | # -- General configuration --------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 25 | 26 | extensions = [ 27 | "sphinx.ext.autodoc", 28 | "sphinx.ext.todo", 29 | "sphinx.ext.autosummary", 30 | "sphinx.ext.autosectionlabel", 31 | "sphinxcontrib.autodoc_pydantic", 32 | "apioptions", 33 | ] 34 | 35 | templates_path = ["_templates"] 36 | exclude_patterns = ["contributors/_autosummary/*.rst"] 37 | 38 | add_module_names = False 39 | 40 | autodoc_typehints = "both" 41 | 42 | autodoc_member_order = "bysource" 43 | autosectionlabel_prefix_document = True 44 | 45 | autodoc_pydantic_model_show_json = False 46 | autodoc_pydantic_model_summary_list_order = "bysource" 47 | autodoc_pydantic_model_show_config = False 48 | autodoc_pydantic_model_show_config_summary = False 49 | # autodoc_pydantic_model_undoc_members = False 50 | autodoc_pydantic_model_show_field_summary = False 51 | autodoc_pydantic_model_member_order = "bysource" 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 55 | 56 | pygments_style = "default" 57 | pygments_dark_style = "material" 58 | 59 | html_static_path = ["_static"] 60 | 61 | html_theme = "furo" 62 | 63 | html_title = f"Jamf Pro SDK
for Python
{release}" 64 | 65 | html_theme_options = { 66 | # "light_logo": "logo.png", 67 | # "dark_logo": "logo-dark.png", 68 | "light_css_variables": { 69 | "color-brand-primary": "#7C4DFF", 70 | "color-brand-content": "#7C4DFF", 71 | }, 72 | "dark_css_variables": { 73 | "color-brand-primary": "#FF9900", 74 | "color-brand-content": "#FF9900", 75 | }, 76 | "footer_icons": [ 77 | { 78 | "name": "GitHub", 79 | "url": "https://github.com/macadmins/jamf-pro-sdk-python", 80 | "html": """ 81 | 82 | 83 | 84 | """, 85 | "class": "", 86 | }, 87 | { 88 | "name": "PyPI", 89 | "url": "https://pypi.org/project/jamf-pro-sdk/", 90 | "html": """ 91 | Python 92 | """, 93 | "class": "", 94 | }, 95 | ], 96 | } 97 | 98 | # Concatenate both the class’ and the __init__ method’s docstrings. 99 | autoclass_content = "both" 100 | -------------------------------------------------------------------------------- /docs/contributors/index.rst: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | There are many ways to directly contribute to the project. You can enhance the documentation and user guides, or add additional API models and methods. For both there are guidelines for how to proceed. 5 | 6 | Pull Requests 7 | ------------- 8 | 9 | Unless you are a project maintainer all pull requests should be made against the ``main`` branch of the project from a fork in your personal GitHub account. It is recommended you work in a feature branch of your fork and periodically pull in changes from ``main``. 10 | 11 | All pull requests must pass all tests and linting as defined in the `main_pr_tests.yaml `_ GitHub Actions workflow. Your branch must be up to date with the ``main`` branch. Be sure to pull in all change and resolve merge conflicts before you open your PR. 12 | 13 | Documentation 14 | ------------- 15 | 16 | The Jamf Pro SDK for Python's documentation is written in `reStructuredText `_ and is built using `Sphinx `_. 17 | 18 | Documentation is a combination of hand written user guides and code references rendering the docstrings. If you are contributing updates to existing user guides, or expanding with new content, try to include easy to follow instructions and explanations interspersed with ``code-block`` examples and/or references to SDK objects. As much as possible keep your contributions consistent with existing content. 19 | 20 | .. important:: 21 | 22 | If you have recommendations for major changes to the documentation - whether rewriting sections or reorganizing content - please open a `Feedback issue `_ first for discussion. 23 | 24 | Code Contributions 25 | ------------------ 26 | 27 | Before writing code and opening a pull request to add to the project, please open a `Feedback issue `_ for discussion of the proposed changes and ensuring duplicate work is not in flight. 28 | 29 | Code contributors are required to uphold project standards: 30 | 31 | * Consistency with existing SDK interfaces. 32 | * All code conforms to formatting and styling (``ruff`` is used in this project and is enforced at pull request). 33 | * All changes are documented in the users guides, references, and docstrings. 34 | * Code changes are covered by additional tests (future: this project will not have a full test suite until it comes out of alpha). 35 | * Backwards compatibility is not broken by the contributions (with the exception of the alpha period where breaking changes may be allowed on a case-by-case basis). 36 | 37 | Your pull request may not be accepted if it does not fulfill all of the requirements listed above. 38 | 39 | Dev Environment Setup 40 | ^^^^^^^^^^^^^^^^^^^^^ 41 | 42 | It is recommended you use a Python virtual environment for local development (see `venv `_. 43 | 44 | .. tip:: 45 | 46 | The ``Makefile`` included in the project includes shortcut commands that will be referenced in this document. You must install ``Xcode Command Line Tools`` on macOS to use the ``make`` command. 47 | 48 | With your virtual environment active run the following from the SDK repository's directory: 49 | 50 | .. code-block:: console 51 | 52 | (.venv) % make install 53 | 54 | This will install the package in editable mode with all dev and optional dependencies. 55 | 56 | There are two additional ``make`` commands for maintaining your local environment. 57 | 58 | * ``uninstall`` will remove **ALL** Python packages from your virtual environment allowing you to reinstall cleanly if needed. 59 | * ``clean`` will remove any directory that's generated by linting, testing, and building the package. This is useful in the event cached files are causing an issue with running commands. 60 | 61 | Code Quality 62 | ^^^^^^^^^^^^ 63 | 64 | The ``ruff`` tool will enforce formatting standards for the codebase. The settings are defined in `pyproject.toml `_. 65 | 66 | * ``make lint`` will check if your code is compliant with the formatting and linting rules without making changes. 67 | * ``make format`` will edit your code to comply with the formatting and linting rules. 68 | 69 | Project code is required to use type hinting from the ``typing`` module for all arguments and returns. These should be documented in the docstring following Sphinx's `signatures `_ syntax. 70 | 71 | The top of a docstring should include a brief description plus a longer explanation of any special behavior. Any reStructureText can be used in docstrings to better format and emphasize content. 72 | 73 | .. code-block:: python 74 | 75 | from typing import Iterable 76 | 77 | from jamf_pro_sdk.models.classic.computers import ClassicComputersItem 78 | 79 | def list_all_computers(self, subsets: Iterable[str] = None) -> List[ClassicComputersItem]: 80 | """Returns a list of all computers. 81 | 82 | :param subsets: (optional) This operations accepts the ``basic`` subset to return 83 | additional details for every computer record. No other subset values are 84 | supported. 85 | :type subsets: Iterable 86 | 87 | :return: List of computers. 88 | :rtype: List[ClassicComputersItem] 89 | 90 | """ 91 | 92 | API Additions 93 | ^^^^^^^^^^^^^ 94 | 95 | Any Jamf Pro API added to the clients must have the following elements code complete before you open a pull request: 96 | 97 | * The API method has been added to the appropriate client, has a complete docstring, and has an interface in-line with other methods of that client. 98 | * The API must have matching and complete Pydantic models. 99 | * Provide ``@overload`` interfaces for API methods with dynamic return types (e.g. ``Union[list[Computer], Iterator[Page]]``) which be determined from the value of an argument. 100 | * If the value of a variable, method argument, or return parameter can be ``None``, inform the type checker by wrapping its type with ``Optional[]``, e.g. ``description: Optional[str] = None`` 101 | * Unless your code is covered by another automated test you will need to add tests to ensure coverage. 102 | 103 | The SDK references in the documentation automatically include all public method on the clients and no documentation changes may be required as a part of the contribution. 104 | 105 | Pro API Pydantic models can be created by referencing the resources from the `OpenAPI schema available on the Jamf Pro server `_. 106 | 107 | * All Pro API Pydantic models must subclass from :class:`~jamf_pro_sdk.models.BaseModel`. This version of Pydantic's ``BaseModel`` will render correctly in the documentation. 108 | 109 | Classic API Pydantic models are more complex as they convert to XML for write operations, and JSON responses from Jamf Pro are known to deviate from the XML representation. If you need to create a Pydantic model for a Classic API resource use the following guidance: 110 | 111 | * All Classic API Pydantic models must start with ``Classic`` in the name to prevent conflicts with Pro API models. 112 | * Use an API response from a running Jamf Pro instance as your reference. 113 | * Base your model on the JSON response. Be sure to note and deviations from the XML representation and include those in the doctrings. 114 | * The top-level Pydantic model should subclass from :class:`~jamf_pro_sdk.models.classic.ClassicApiModel`. 115 | * Override the ``_xml_root_name``. This value should be the top-level key name in the XML (e.g. ``computer``). 116 | * Override the ``_xml_array_item_names`` if applicable. This is a dictionary of attribute name to the XML array item key name (e.g. the ``extension_attributes`` array has ``extension_attribute`` items). This mapping is used during XML generation for write operations. 117 | * Override ``_xml_write_fields``. This is a Python set of strings that represent the writeable attributes for the object. By default, only these attributes are included when XML is generated from a model. 118 | * All other nested Classic API Pydantic models must subclass from :class:`~jamf_pro_sdk.models.BaseModel`. This version of Pydantic's ``BaseModel`` will render correctly in the documentation. 119 | 120 | All API models must be added in the model documentation pages. Model docs are separated by type or API grouping (e.g. Computers, Mobile Devices, etc.). List the top-level model first, and then the nested models for that type in order after. 121 | 122 | Here is an example from the Classic API Models page. Follow this pattern for all new model sections. 123 | 124 | .. code-block:: rst 125 | 126 | Computer Groups 127 | --------------- 128 | 129 | .. currentmodule:: jamf_pro_sdk.models.classic.computer_groups 130 | 131 | .. autosummary:: 132 | :toctree: _autosummary 133 | :nosignatures: 134 | 135 | ClassicComputerGroup 136 | ClassicComputerGroupMember 137 | ClassicComputerGroupMembershipUpdate 138 | 139 | Other 140 | ----- 141 | 142 | .. toctree:: 143 | :maxdepth: 1 144 | 145 | playground 146 | -------------------------------------------------------------------------------- /docs/contributors/playground.rst: -------------------------------------------------------------------------------- 1 | Playground 2 | ========== 3 | 4 | .. admonition:: Admonition 5 | 6 | Admonition 7 | 8 | .. note:: 9 | 10 | Note 11 | 12 | .. hint:: 13 | 14 | Hint 15 | 16 | .. tip:: 17 | 18 | Tip 19 | 20 | .. important:: 21 | 22 | Important 23 | 24 | .. attention:: 25 | 26 | Attention 27 | 28 | .. caution:: 29 | 30 | Caution 31 | 32 | .. warning:: 33 | 34 | Warning 35 | 36 | .. danger:: 37 | 38 | Danger 39 | 40 | .. error:: 41 | 42 | Error 43 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Jamf Pro SDK for Python documentation master file, created by 2 | sphinx-quickstart on Wed Jan 4 12:42:19 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Jamf Pro SDK for Python 7 | ======================= 8 | 9 | A Python library for developing against the Jamf Pro APIs. 10 | 11 | .. note:: 12 | 13 | This project is under active development. 14 | 15 | If you are writing scripts or building applications that interact with the Jamf Pro Classic or Pro 16 | APIs proceed to the **User Guide** to get started. If you are building webhook-based integrations 17 | for real-time operations proceed to the **Webhooks** section to learn about the tools in this SDK 18 | to assist you. 19 | 20 | User Guides 21 | ----------- 22 | 23 | Learn how to use the Jamf Pro SDK clients with walkthroughs and examples. 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | 28 | user/index 29 | 30 | Webhooks 31 | -------- 32 | 33 | Leverage webhook models for event validation and a client for simulating webhook events to a live server. 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | 38 | webhooks/index 39 | 40 | SDK Reference 41 | ------------- 42 | 43 | Read developer documentation on the classes and models within the SDK. 44 | 45 | .. toctree:: 46 | :maxdepth: 1 47 | 48 | reference/index 49 | 50 | Contributors 51 | ------------ 52 | 53 | Resources and guides for contributors to the Jamf Pro SDK. 54 | 55 | .. toctree:: 56 | :maxdepth: 2 57 | 58 | contributors/index 59 | -------------------------------------------------------------------------------- /docs/reference/clients.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | Jamf Pro Client 4 | =============== 5 | 6 | .. autoclass:: jamf_pro_sdk.clients.JamfProClient 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/reference/clients_classic.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 3 2 | 3 | Classic API Client 4 | ================== 5 | 6 | .. autoclass:: jamf_pro_sdk.clients.classic_api.ClassicApi 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/reference/clients_jcds2.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 3 2 | 3 | JCDS2 Client 4 | ============ 5 | 6 | .. autoclass:: jamf_pro_sdk.clients.jcds2.JCDS2 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/reference/clients_pro.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 3 2 | 3 | Pro API Client 4 | ============== 5 | 6 | .. autoclass:: jamf_pro_sdk.clients.pro_api.ProApi 7 | :members: 8 | 9 | Pagination 10 | ========== 11 | 12 | .. autoclass:: jamf_pro_sdk.clients.pro_api.pagination.Paginator 13 | :members: 14 | :special-members: __call__ 15 | 16 | .. autoclass:: jamf_pro_sdk.clients.pro_api.pagination.FilterExpression 17 | :members: 18 | 19 | .. autoclass:: jamf_pro_sdk.clients.pro_api.pagination.FilterField 20 | :members: 21 | 22 | .. autofunction:: jamf_pro_sdk.clients.pro_api.pagination.filter_group 23 | 24 | .. autoclass:: jamf_pro_sdk.clients.pro_api.pagination.SortExpression 25 | :members: 26 | 27 | .. autoclass:: jamf_pro_sdk.clients.pro_api.pagination.SortField 28 | :members: 29 | -------------------------------------------------------------------------------- /docs/reference/clients_webhooks.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 3 2 | 3 | Webhooks Client 4 | =============== 5 | 6 | .. autoclass:: jamf_pro_sdk.clients.webhooks.WebhooksClient 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/reference/credentials.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 3 2 | 3 | Credentials Providers 4 | ===================== 5 | 6 | The Jamf Pro SDK has two primary types of credential providers: **API Client Credentials** and **User Credentials**. 7 | 8 | API Client Credentials Provider 9 | ------------------------------- 10 | 11 | Use Jamf Pro `API clients `_ for API authentication. 12 | 13 | .. autoclass:: jamf_pro_sdk.clients.auth.ApiClientCredentialsProvider 14 | :members: 15 | 16 | User Credentials Provider 17 | ------------------------- 18 | 19 | User credential providers use a username and password for API authentication. 20 | 21 | .. autoclass:: jamf_pro_sdk.clients.auth.UserCredentialsProvider 22 | :members: 23 | 24 | Utilities for Credential Providers 25 | ---------------------------------- 26 | 27 | These functions return an instantiated credentials provider of the specified type. 28 | 29 | Prompt for Credentials 30 | ^^^^^^^^^^^^^^^^^^^^^^ 31 | 32 | .. autofunction:: jamf_pro_sdk.clients.auth.prompt_for_credentials 33 | 34 | Load from AWS Secrets Manager 35 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 36 | 37 | .. autofunction:: jamf_pro_sdk.clients.auth.load_from_aws_secrets_manager 38 | 39 | Load from Keychain 40 | ^^^^^^^^^^^^^^^^^^ 41 | 42 | .. autofunction:: jamf_pro_sdk.clients.auth.load_from_keychain 43 | 44 | Access Token 45 | ------------ 46 | 47 | .. autopydantic_model:: jamf_pro_sdk.models.client.AccessToken 48 | :undoc-members: false 49 | 50 | Credentials Provider Base Class 51 | ------------------------------- 52 | 53 | .. autoclass:: jamf_pro_sdk.clients.auth.CredentialsProvider 54 | :members: 55 | :private-members: 56 | -------------------------------------------------------------------------------- /docs/reference/helpers.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | Helpers 4 | ======= 5 | 6 | .. automodule:: jamf_pro_sdk.helpers 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | SDK Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | clients 8 | credentials 9 | clients_classic 10 | models_classic 11 | clients_pro 12 | models_pro 13 | clients_jcds2 14 | clients_webhooks 15 | webhook_generators 16 | models_webhooks 17 | helpers 18 | -------------------------------------------------------------------------------- /docs/reference/models_classic.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | Classic API Models 4 | ================== 5 | 6 | .. note:: 7 | 8 | The intended use of SDK models is to provide an easier developer experience when working with 9 | requests and responses. All fields are optional, and extra fields are allowed for when Jamf 10 | releases a new version that adds additional fields that are not yet reflected in the SDK. These 11 | extra fields when present may or may not reflect their actual types. Do not rely solely on the 12 | SDK models for data validation. 13 | 14 | Shared Models 15 | ------------- 16 | 17 | .. currentmodule:: jamf_pro_sdk.models.classic 18 | 19 | .. autosummary:: 20 | :toctree: _autosummary 21 | 22 | ClassicApiModel 23 | ClassicDeviceLocation 24 | ClassicDevicePurchasing 25 | ClassicSite 26 | 27 | Categories 28 | ---------- 29 | 30 | .. currentmodule:: jamf_pro_sdk.models.classic.categories 31 | 32 | .. autosummary:: 33 | :toctree: _autosummary 34 | 35 | ClassicCategory 36 | ClassicCategoriesItem 37 | 38 | Advanced Computer Searches 39 | -------------------------- 40 | 41 | .. currentmodule:: jamf_pro_sdk.models.classic.advanced_computer_searches 42 | 43 | .. autosummary:: 44 | :toctree: _autosummary 45 | 46 | ClassicAdvancedComputerSearch 47 | ClassicAdvancedComputerSearchesItem 48 | ClassicAdvancedComputerSearchResult 49 | ClassicAdvancedComputerSearchDisplayField 50 | 51 | Computers 52 | --------- 53 | 54 | .. currentmodule:: jamf_pro_sdk.models.classic.computers 55 | 56 | .. autosummary:: 57 | :toctree: _autosummary 58 | 59 | ClassicComputer 60 | ClassicComputersItem 61 | ClassicComputerGeneral 62 | ClassicComputerGeneralRemoteManagement 63 | ClassicComputerGeneralMdmCapableUsers 64 | ClassicComputerGeneralManagementStatus 65 | ClassicComputerHardware 66 | ClassicComputerHardwareStorageDevice 67 | ClassicComputerHardwareStorageDevicePartition 68 | ClassicComputerHardwareMappedPrinter 69 | ClassicComputerCertificate 70 | ClassicComputerSecurity 71 | ClassicComputerSoftware 72 | ClassicComputerSoftwareItem 73 | ClassicComputerSoftwareAvailableUpdate 74 | ClassicComputerExtensionAttribute 75 | ClassicComputerGroupsAccounts 76 | ClassicComputerGroupsAccountsLocalAccount 77 | ClassicComputerGroupsAccountsUserInventories 78 | ClassicComputerGroupsAccountsUserInventoriesUser 79 | ClassicComputerConfigurationProfile 80 | 81 | Computer Groups 82 | --------------- 83 | 84 | .. currentmodule:: jamf_pro_sdk.models.classic.computer_groups 85 | 86 | .. autosummary:: 87 | :toctree: _autosummary 88 | :nosignatures: 89 | 90 | ClassicComputerGroup 91 | ClassicComputerGroupMember 92 | ClassicComputerGroupMembershipUpdate 93 | 94 | Network Segments 95 | ---------------- 96 | 97 | .. currentmodule:: jamf_pro_sdk.models.classic.network_segments 98 | 99 | .. autosummary:: 100 | :toctree: _autosummary 101 | 102 | ClassicNetworkSegment 103 | ClassicNetworkSegmentItem 104 | 105 | Packages 106 | -------- 107 | 108 | .. currentmodule:: jamf_pro_sdk.models.classic.packages 109 | 110 | .. autosummary:: 111 | :toctree: _autosummary 112 | 113 | ClassicPackage 114 | ClassicPackageItem 115 | 116 | Group and Search Criteria 117 | ------------------------- 118 | 119 | .. currentmodule:: jamf_pro_sdk.models.classic.criteria 120 | 121 | .. autosummary:: 122 | :toctree: _autosummary 123 | 124 | ClassicCriterion 125 | ClassicCriterionSearchType 126 | -------------------------------------------------------------------------------- /docs/reference/models_pro.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | Pro API Models 4 | ============== 5 | 6 | .. note:: 7 | 8 | The intended use of SDK models is to provide an easier developer experience when working with 9 | requests and responses. All fields are optional, and extra fields are allowed for when Jamf 10 | releases a new version that adds additional fields that are not yet reflected in the SDK. These 11 | extra fields when present may or may not reflect their actual types. Do not rely solely on the 12 | SDK models for data validation. 13 | 14 | Computers 15 | --------- 16 | 17 | .. currentmodule:: jamf_pro_sdk.models.pro.computers 18 | 19 | .. autosummary:: 20 | :toctree: _autosummary 21 | 22 | Computer 23 | ComputerGeneral 24 | ComputerDiskEncryption 25 | ComputerPurchase 26 | ComputerApplication 27 | ComputerStorage 28 | ComputerUserAndLocation 29 | ComputerConfigurationProfile 30 | ComputerPrinter 31 | ComputerService 32 | ComputerHardware 33 | ComputerLocalUserAccount 34 | ComputerCertificate 35 | ComputerAttachment 36 | ComputerPlugin 37 | ComputerPackageReceipts 38 | ComputerFont 39 | ComputerSecurity 40 | ComputerOperatingSystem 41 | ComputerLicensedSoftware 42 | ComputeriBeacon 43 | ComputerSoftwareUpdate 44 | ComputerExtensionAttribute 45 | ComputerContentCaching 46 | ComputerGroupMembership 47 | 48 | Packages 49 | -------- 50 | 51 | .. currentmodule:: jamf_pro_sdk.models.pro.packages 52 | 53 | .. autosummary:: 54 | :toctree: _autosummary 55 | 56 | Package 57 | 58 | JCDS2 59 | ----- 60 | 61 | .. currentmodule:: jamf_pro_sdk.models.pro.jcds2 62 | 63 | .. autosummary:: 64 | :toctree: _autosummary 65 | 66 | NewFile 67 | File 68 | DownloadUrl 69 | 70 | .. _MDM Command Models: 71 | 72 | MDM Commands 73 | ------------ 74 | 75 | .. currentmodule:: jamf_pro_sdk.models.pro.mdm 76 | 77 | .. autosummary:: 78 | :toctree: _autosummary 79 | 80 | SendMdmCommand 81 | SendMdmCommandClientData 82 | EnableLostModeCommand 83 | EraseDeviceCommand 84 | EraseDeviceCommandObliterationBehavior 85 | EraseDeviceCommandReturnToService 86 | LogOutUserCommand 87 | RestartDeviceCommand 88 | ShutDownDeviceCommand 89 | SendMdmCommandResponse 90 | RenewMdmProfileResponse 91 | MdmCommandStatus 92 | MdmCommandStatusClient 93 | MdmCommandStatusClientTypes 94 | MdmCommandStatusStates 95 | MdmCommandStatusTypes 96 | 97 | Mobile Devices 98 | -------------- 99 | 100 | .. currentmodule:: jamf_pro_sdk.models.pro.mobile_devices 101 | 102 | .. autosummary:: 103 | :toctree: _autosummary 104 | 105 | MobileDevice 106 | MobileDeviceHardware 107 | MobileDeviceUserAndLocation 108 | MobileDevicePurchasing 109 | MobileDeviceApplication 110 | MobileDeviceCertificate 111 | MobileDeviceProfile 112 | MobileDeviceUserProfile 113 | MobileDeviceExtensionAttribute 114 | MobileDeviceGeneral 115 | MobileDeviceOwnershipType 116 | MobileDeviceEnrollmentMethodPrestage 117 | MobileDeviceSecurity 118 | MobileDeviceEbook 119 | MobileDeviceNetwork 120 | MobileDeviceServiceSubscription 121 | ProvisioningProfile 122 | SharedUser 123 | 124 | Pagination 125 | ---------- 126 | 127 | .. currentmodule:: jamf_pro_sdk.clients.pro_api.pagination 128 | 129 | .. autosummary:: 130 | :toctree: _autosummary 131 | 132 | Page 133 | FilterEntry 134 | -------------------------------------------------------------------------------- /docs/reference/models_webhooks.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | Webhook Models 4 | ============== 5 | 6 | .. note:: 7 | 8 | The intended use of SDK models is to provide an easier developer experience when working with 9 | requests and responses. All fields are optional, and extra fields are allowed for when Jamf 10 | releases a new version that adds additional fields that are not yet reflected in the SDK. These 11 | extra fields when present may or may not reflect their actual types. Do not rely solely on the 12 | SDK models for data validation. 13 | 14 | Webhooks 15 | -------- 16 | 17 | .. currentmodule:: jamf_pro_sdk.models.webhooks.webhooks 18 | 19 | .. autosummary:: 20 | :toctree: _autosummary 21 | 22 | ComputerAdded 23 | ComputerCheckIn 24 | ComputerInventoryCompleted 25 | ComputerPolicyFinished 26 | ComputerPushCapabilityChanged 27 | DeviceAddedToDep 28 | JssShutdown 29 | JssStartup 30 | MobileDeviceCheckIn 31 | MobileDeviceEnrolled 32 | MobileDevicePushSent 33 | MobileDeviceUnEnrolled 34 | PushSent 35 | RestApiOperation 36 | SmartGroupComputerMembershipChange 37 | SmartGroupMobileDeviceMembershipChange 38 | SmartGroupUserMembershipChange 39 | 40 | Event Models 41 | ------------ 42 | 43 | .. currentmodule:: jamf_pro_sdk.models.webhooks.webhooks 44 | 45 | .. autosummary:: 46 | :toctree: _autosummary 47 | 48 | WebhookData 49 | ComputerEvent 50 | MobileDeviceEvent 51 | ComputerAddedWebhook 52 | ComputerCheckInWebhook 53 | ComputerCheckInEvent 54 | ComputerInventoryCompletedWebhook 55 | ComputerPolicyFinishedWebhook 56 | ComputerPolicyFinishedEvent 57 | ComputerPushCapabilityChangedWebhook 58 | DeviceAddedToDepWebhook 59 | DeviceAddedToDepEvent 60 | JssShutdownWebhook 61 | JssStartupWebhook 62 | JssStartupShutdownEvent 63 | MobileDeviceCheckInWebhook 64 | MobileDeviceEnrolledWebhook 65 | MobileDevicePushSentWebhook 66 | MobileDeviceUnEnrolledWebhook 67 | PushSentWebhook 68 | PushSentEvent 69 | RestApiOperationWebhook 70 | RestApiOperationEvent 71 | SmartGroupComputerMembershipChangeWebhook 72 | SmartGroupComputerMembershipChangeEvent 73 | SmartGroupMobileDeviceMembershipChangeWebhook 74 | SmartGroupMobileDeviceMembershipChangeEvent 75 | SmartGroupUserMembershipChangeWebhook 76 | SmartGroupUserMembershipChangeEvent 77 | -------------------------------------------------------------------------------- /docs/reference/webhook_generators.rst: -------------------------------------------------------------------------------- 1 | Webhook Generators 2 | ================== 3 | 4 | Generator Loading 5 | ----------------- 6 | 7 | .. autofunction:: jamf_pro_sdk.clients.webhooks.get_webhook_generator 8 | 9 | .. autofunction:: jamf_pro_sdk.clients.webhooks._load_webhook_generators 10 | 11 | Field Mocking 12 | ------------- 13 | 14 | .. autofunction:: jamf_pro_sdk.clients.webhooks.epoch 15 | 16 | .. autofunction:: jamf_pro_sdk.clients.webhooks.serial_number 17 | 18 | .. autofunction:: jamf_pro_sdk.clients.webhooks.udid 19 | -------------------------------------------------------------------------------- /docs/user/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced Usage 2 | ============== 3 | 4 | Custom Credentials Providers 5 | ---------------------------- 6 | 7 | A :class:`~jamf_pro_sdk.clients.auth.CredentialsProvider` is an interface for the SDK to obtain access tokens. The SDK comes with a number of built-in options that are detailed in the :doc:`/reference/credentials` reference. You can create your own provider by inheriting from the ``CredentialsProvider`` base class and overriding the ``_refresh_access_token`` method. 8 | 9 | The following example does not accept a username or password and retrieves a token from a DynamoDB table in an AWS account (it is assumed an external process is managing this table entry). 10 | 11 | .. code-block:: python 12 | 13 | >>> import boto3 14 | >>> from jamf_pro_sdk.clients.auth import CredentialsProvider 15 | >>> from jamf_pro_sdk.models.client import AccessToken 16 | >>> 17 | >>> class DynamoDBProvider(CredentialsProvider): 18 | ... def __init__(self, table_name: str): 19 | ... self.table = boto3.resource("dynamodb").Table(table_name) 20 | ... super().__init__() 21 | ... @property 22 | ... def _request_access_token(self) -> AccessToken: 23 | ... item = table.get_item(Key={"pk": "access-token"})["Item"] 24 | ... return AccessToken(type="user", token=item["token"], expires=item["expires"]) 25 | ... 26 | >>> creds = DynamoDBProvider("my-table") 27 | >>> creds.get_access_token() 28 | AccessToken(type='user', token='eyJhbGciOiJIUzI1NiJ9...' ...) 29 | >>> 30 | 31 | The built-in providers retrieve and store the username and password values on initialization, but by leveraging the override method shown above you can write providers that read/cache from remote locations on each invoke. 32 | 33 | Using Unsupported APIs 34 | ---------------------- 35 | 36 | The SDK's clients provide curated methods to a large number of Jamf Pro APIs. Not all APIs may be implemented, and newer APIs may not be accounted for. You can still leverage the client to request any API without using the curated methods while still taking advantage of the client's session features and token management. 37 | 38 | Here is the built-in method for getting a computer from the Classic API: 39 | 40 | .. code-block:: python 41 | 42 | >>> computer = client.classic_api.get_computer_by_id(1) 43 | >>> type(computer) 44 | 45 | >>> 46 | 47 | The same operation can be performed by using the :meth:`~jamf_pro_sdk.clients.JamfProClient.classic_api_request` method directly: 48 | 49 | .. code-block:: python 50 | 51 | >>> response = client.classic_api_request(method='get', resource_path='computers/id/1') 52 | >>> type(response) 53 | 54 | 55 | This returns the ``requests.Response`` object unaltered. Note that in the ``resource_path`` argument you do not need to provide `JSSResource`. 56 | 57 | Performing Concurrent Operations 58 | -------------------------------- 59 | 60 | The SDK supports multi-threaded use. The cached access token utilizes a thread lock to prevent multiple threads from refreshing the token if it is expiring. The Jamf Pro client contains a helper method for performing concurrent operations. 61 | 62 | Consider the need to perform a mass read operation on computer records. Serially this could take hours for Jamf Pro users with thousands or tens of thousands of devices. With even a concurrency of two the amount of time required can be cut nearly in half. 63 | 64 | Here is a code example using :meth:`~jamf_pro_sdk.clients.JamfProClient.concurrent_api_requests` to perform a mass :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.get_computer_by_id` operation: 65 | 66 | .. code-block:: python 67 | 68 | from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 69 | 70 | # The default concurrency setting is 10. 71 | client = JamfProClient( 72 | server="jamf.my.org", 73 | credentials=ApiClientCredentialsProvider("client_id", "client_secret") 74 | ) 75 | 76 | # Get a list of all computers, and then their IDs. 77 | all_computers = client.classic_api.list_all_computers() 78 | all_computer_ids = [c.id for c in all_computers] 79 | 80 | # Pass the API operation and list of IDs into the `concurrent_api_requests()` method. 81 | results = client.concurrent_api_requests( 82 | handler=client.classic_api.get_computer_by_id, 83 | arguments=all_computer_ids 84 | ) 85 | 86 | # Iterate over the results. 87 | for r in results: 88 | print(r.general.id, r.general.name, r.location.username) 89 | 90 | The ``handler`` is any callable function. 91 | 92 | The ``arguments`` can be any iterable. Each item within the iterable is passed to the handler as its argument. If your handler takes multiple arguments you can use a ``dict`` which will be unpacked automatically. 93 | 94 | Here is the functional code as above but using the ```~jamf_pro_sdk.clients.JamfProClient.classic_api_request`` method: 95 | 96 | .. code-block:: python 97 | 98 | # Construct the arguments by iterating over the computer IDs and creating the argument dictionary 99 | results = client.concurrent_api_requests( 100 | handler=client.classic_api_request, 101 | arguments=[{"method": "get", "resource_path": f"computers/id/{i.id}"} for i in all_computer_ids], 102 | return_model=Computer 103 | ) 104 | 105 | # Iterate over the results. 106 | for r in results: 107 | print(r.general.id, r.general.name, r.location.username) 108 | 109 | If you have to perform more complex logic in the threaded operations you can wrap it into another function and pass that. Here is an example that is performing a read following by a conditional update. 110 | 111 | .. code-block:: python 112 | 113 | def wrapper(computer_id, new_building): 114 | current = client.get_computer_by_id(computer_id, subsets=["location"]) 115 | update = Computer() 116 | if current.location.building in ("Day 1", "Low Flying Hawk"): 117 | update.location.building = new_building 118 | else: 119 | return "Not Updated" 120 | 121 | client.update_computer_by_id(computer_id, ) 122 | return "Updated" 123 | 124 | results = client.concurrent_api_requests( 125 | wrapper, [{"computer_id": 1, "new_building": ""}] 126 | ) 127 | -------------------------------------------------------------------------------- /docs/user/classic_api.rst: -------------------------------------------------------------------------------- 1 | Classic API 2 | =========== 3 | 4 | The Classic API only accepts XML for write operations, but allows JSON for read operations. The Classic API interface for the SDK only accepts JSON data in read responses. API responses are returned as data models that can be more easily interacted with using dot notation. 5 | 6 | Read Requests 7 | ------------- 8 | 9 | The curated methods return data models of the JSON response. Data models can be interacted with using dot notation. 10 | 11 | .. code-block:: python 12 | 13 | >>> computers = client.classic_api.list_all_computers() 14 | >>> len(computers) 15 | 4 16 | >>> type(computers[0]) 17 | 18 | >>> for c in computers: 19 | ... print(c.name) 20 | ... 21 | Oscar's MacBook Air 22 | Chip's MacBook Pro 23 | Zach's MacBook Air 24 | Bryson’s MacBook Pro 25 | >>> 26 | 27 | Some Classic API operations support ``subsets`` which extend or limit the data that is returned: 28 | 29 | .. code-block:: python 30 | 31 | >>> computers = client.classic_api.list_all_computers(subsets=["basic"]) 32 | >>> computers[0] 33 | ComputersItem(id=1, name="Oscar's MacBook Air", managed=True, username='oscar', model='MacBookPro18,3', department='', building='', mac_address='00:1A:2B:CD:34:FF', udid='2AD4F6B0-3926-4305-B567-C1FB93F36768', serial_number='TGIF772PLY', report_date_utc=datetime.datetime(2022, 12, 16, 22, 38, 51, 347000, tzinfo=datetime.timezone.utc), report_date_epoch=1671230331347) 34 | >>> 35 | 36 | ISO 8601 date fields (generally, fields that end with `_date_utc`) are automatically converted into ``datetime.datetime`` objects: 37 | 38 | .. code-block:: python 39 | 40 | >>> computers[0].report_date_utc 41 | datetime.datetime(2022, 12, 16, 22, 38, 51, 347000, tzinfo=datetime.timezone.utc) 42 | >>> 43 | 44 | The data models can also be converted back into a Python ``dict``: 45 | 46 | .. code-block:: python 47 | 48 | >>> computers[0].dict() 49 | {'id': 1, 'name': "Oscar's MacBook Pro", 'managed': True, 'username': 'oscar', 'model': 'MacBookPro18,3', 'department': '', 'building': '', 'mac_address': '00:1A:2B:CD:34:FF"', 'udid': '2AD4F6B0-3926-4305-B567-C1FB93F36768', 'serial_number': 'TGIF772PLY', 'report_date_utc': datetime.datetime(2022, 12, 16, 22, 38, 51, 347000, tzinfo=datetime.timezone.utc), 'report_date_epoch': 1671230331347} 50 | >>> 51 | 52 | .. tip:: 53 | 54 | You can browse the available data models at :doc:`/reference/models_classic`. 55 | 56 | Write Requests 57 | -------------- 58 | 59 | The Classic API only accepts XML for ``POST`` and ``PUT`` operations. The SDK accepts XML strings if you are generating this data, or you can leverage the data models and their built-in XML generation to do the work for you. 60 | 61 | Here is an example where an extension attribute value is being updated: 62 | 63 | .. code-block:: python 64 | 65 | >>> from jamf_pro_sdk.models.classic.computers import ClassicComputer, ClassicComputerExtensionAttribute 66 | >>> computer_update = ClassicComputer() 67 | >>> computer_update.extension_attributes.append(ClassicComputerExtensionAttribute(id=1, value="new")) 68 | >>> computer_update.xml() 69 | '1new' 70 | >>> 71 | 72 | You can also construct the update as a ``dict`` and pass that into the model: 73 | 74 | .. code-block:: python 75 | 76 | >>> data = {"extension_attributes": [{"id": 1, "value": "new"}]} 77 | >>> computer_update = ClassicComputer(**data) 78 | >>> computer_update.xml() 79 | '1new' 80 | >>> 81 | 82 | The SDK's data models perform type checking and some validation. By using the data models you can prevent invalid data from being set. 83 | 84 | .. code-block:: python 85 | 86 | >>> bad_data = {"extension_attributes": {"id": 1, "value": "new"}} 87 | >>> ClassicComputer(**bad_data) 88 | Traceback (most recent call last): 89 | File "", line 1, in 90 | File "/jamf-pro-sdk/jamf_pro_sdk/models/__init__.py", line 10, in __init__ 91 | super(BaseModel, self).__init__(*args, **kwargs) 92 | File "pydantic/main.py", line 342, in pydantic.main.BaseModel.__init__ 93 | pydantic.error_wrappers.ValidationError: 1 validation error for ClassicComputer 94 | extension_attributes 95 | value is not a valid list (type=type_error.list) 96 | >>> 97 | 98 | The XML string or SDK data model are passed to the ``data`` argument for write operations. 99 | The SDK handles converting data models to XML. 100 | 101 | .. code-block:: python 102 | 103 | >>> xml = '1new' 104 | >>> client.classic_api.update_computer_by_id(computer_id=1, data=xml) 105 | 106 | >>> data = {"extension_attributes": [{"id": 1, "value": "new"}]} 107 | >>> computer_update = ClassicComputer(**data) 108 | >>> client.classic_api.update_computer_by_id(computer_id=1, data=computer_update) 109 | 110 | Example Usage 111 | ------------- 112 | 113 | Assume this client has been instantiated for the examples shown below. 114 | 115 | .. code-block:: python 116 | 117 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 118 | >>> client = JamfProClient( 119 | ... server="jamf.my.org", 120 | ... credentials=ApiClientCredentialsProvider("client_id", "client_secret") 121 | ... ) 122 | >>> 123 | 124 | 125 | Update a Computer's Location 126 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 127 | 128 | You can selectively update fields on a computer record by creating a ``ClassicComputer`` object and setting the desired fields, passing a dictionary with a model, or a raw XML string. 129 | 130 | Using the model: 131 | 132 | .. code-block:: python 133 | 134 | >>> from jamf_pro_sdk.models.classic.computers import ClassicComputer 135 | >>> computer_update = ClassicComputer() 136 | >>> computer_update.location.username = "amy" 137 | >>> computer_update.location.real_name = "Amy" 138 | >>> computer_update.location.email_address = "amy@my.org" 139 | >>> computer_update.xml() 140 | 'amyAmyamy@my.org' 141 | >>> client.classic_api.update_computer_by_id(5, computer_update) 142 | >>> 143 | 144 | Using a dictionary: 145 | 146 | .. code-block:: python 147 | 148 | >>> dict_update = {'username': 'amy', 'real_name': 'Amy', 'email_address': 'amy@my.org'} 149 | >>> client.classic_api.update_computer_by_id(5, ClassicComputer(**dict_update)) 150 | >>> 151 | 152 | Using a raw XML string: 153 | 154 | .. code-block:: python 155 | 156 | >>> xml_update = """ 157 | ... 158 | ... amy 159 | ... Amy 160 | ... amy@my.org 161 | ... 162 | ... """ 163 | >>> client.classic_api.update_computer_by_id(5, xml_update) 164 | >>> 165 | 166 | 167 | Update a Static Computer Group's Membership 168 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 169 | 170 | Static group memberships are modified by providing an iterable of either device IDs (integers) or ``ClassicComputerGroupMember`` objects. Passing in the objects is a handy shortcut when iterating over membership results and selecting devices to add/remove from the same group or another. 171 | 172 | .. code-block:: python 173 | 174 | >>> client.classic_api.get_computer_group_by_id(3) 175 | ClassicComputerGroup(id=3, name='Test Group 1', is_smart=False, site=Site(id=-1, name='None'), criteria=[], computers=[]) 176 | >>> 177 | 178 | Passing an array with an ID: 179 | 180 | .. code-block:: python 181 | 182 | >>> client.classic_api.update_static_computer_group_membership_by_id(3, computers_to_add=[10]) 183 | >>> client.classic_api.get_computer_group_by_id(3)).computers 184 | [ClassicComputerGroupMember(id=10, name='YohnkBook', mac_address='25:3f:d9:ec:d5:b6', alt_mac_address='77:81:eb:54:b2:6a', serial_number='CJYQC70IW2T3')] 185 | 186 | Passing a ``ComputerGroupMember`` object: 187 | 188 | .. code-block:: python 189 | 190 | >>> from jamf_pro_sdk.models.classic.computer_groups import ClassicComputerGroupMember 191 | >>> new_member = ClassicComputerGroupMember(id=10) 192 | >>> client.classic_api.update_static_computer_group_membership_by_id(3, computers_to_add=[new_member]) 193 | >>> client.classic_api.get_computer_group_by_id(3)).computers 194 | [ClassicComputerGroupMember(id=10, name='YohnkBook', mac_address='25:3f:d9:ec:d5:b6', alt_mac_address='77:81:eb:54:b2:6a', serial_number='CJYQC70IW2T3')] 195 | -------------------------------------------------------------------------------- /docs/user/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Install 5 | ------- 6 | 7 | Install the SDK from PyPI. The example is shown with your virtual environment active. 8 | 9 | .. code-block:: console 10 | 11 | (.venv) % python -m pip install jamf-pro-sdk 12 | 13 | Install Locally 14 | --------------- 15 | 16 | Install locally into your virtual environment. You must first clone the SDK repository. The example is shown with your virtual environment active. 17 | 18 | .. code-block:: console 19 | 20 | (.venv) % python -m pip install /path/to/jamf-pro-sdk-python 21 | 22 | When running ``pip freeze`` the SDK will appear with a filepath to the source instead of the version. 23 | 24 | .. code-block:: console 25 | 26 | (.venv) % pip freeze 27 | ... 28 | jamf-pro-sdk @ file:///path/to/jamf-pro-sdk-python 29 | ... 30 | 31 | Create a Client 32 | --------------- 33 | 34 | Create a client object using an API Client ID and Client Secret - the **recommended** method for authentication: 35 | 36 | .. important:: 37 | 38 | **Breaking Change**: As of version ``0.8a1``, the SDK no longer uses ``BasicAuthProvider`` objects. Use :class:`~jamf_pro_sdk.clients.auth.ApiClientCredentialsProvider` as the new default. 39 | 40 | `Basic authentication is now disabled by default `_ in Jamf Pro. To authenticate securely and ensure compatibility with future Jamf Pro versions, use an API Client for access tokens instead. 41 | 42 | .. code-block:: python 43 | 44 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 45 | >>> client = JamfProClient( 46 | ... server="jamf.my.org", 47 | ... credentials=ApiClientCredentialsProvider("client_id", "client_secret") 48 | ... ) 49 | >>> 50 | 51 | .. _server_scheme: 52 | 53 | .. note:: 54 | 55 | When passing your Jamf Pro server name, do not include the scheme (``https://``) as the SDK handles this automatically for you. 56 | 57 | Choosing a Credential Provider 58 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 59 | 60 | There are a number of built-in :doc:`/reference/credentials` available. To learn how to implement your own visit :ref:`user/advanced:Custom Credentials Providers`. 61 | 62 | **We recommend using API Clients** for most cases. Basic authentication via username and password is now considered a legacy method and is **disabled by default** in Jamf Pro versions ≥ 10.49. 63 | 64 | - Use :class:`~jamf_pro_sdk.clients.auth.ApiClientCredentialsProvider` for API Clients. 65 | - Use :class:`~jamf_pro_sdk.clients.auth.UserCredentialsProvider` if enabled in your Jamf environment. 66 | 67 | .. important:: 68 | 69 | **Do not use plaintext secrets (passwords, clients secrets, etc.) in scripts or the console.** The use of the base ``UserCredentialsProvider`` class in this guide is for demonstration purposes. 70 | 71 | Credential Provider Utility Functions 72 | ------------------------------------- 73 | 74 | The SDK contains three helper functions that will *return* an instantiated credential provider of the specified type. When leveraging these functions, ensure you have the required extra dependencies installed. 75 | 76 | When using ``load_from_keychain``, **you must provide the identity keyword argument** required by the specified provider: 77 | 78 | - ``username=`` for ``UserCredentialsProvider`` 79 | - ``client_id=`` for ``ApiClientCredentialsProvider`` 80 | 81 | Prompting for Credentials 82 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 83 | 84 | .. code-block:: python 85 | 86 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider, prompt_for_credentials 87 | >>> client = JamfProClient( 88 | ... server="jamf.my.org", 89 | ... credentials=prompt_for_credentials( 90 | ... provider_type=ApiClientCredentialsProvider 91 | ... ) 92 | ... ) 93 | API Client ID: 123456abcdef 94 | API Client Secret: 95 | 96 | Loading from AWS Secrets Manager 97 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 98 | 99 | .. important:: 100 | 101 | The ``aws`` dependency is required for this function and can be installed via: 102 | 103 | .. code-block:: console 104 | 105 | % python3 -m pip install 'jamf-pro-sdk[aws]' 106 | 107 | The ``SecretString`` is expected to be a JSON string in the following format: 108 | 109 | .. code-block:: json 110 | 111 | // For UserCredentialsProvider: 112 | { 113 | "username": "oscar", 114 | "password": "******" 115 | } 116 | 117 | // For ApiClientCredentialsProvider: 118 | { 119 | "client_id": "abc123", 120 | "client_secret": "xyz456" 121 | } 122 | 123 | .. code-block:: python 124 | 125 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider, load_from_aws_secrets_manager 126 | >>> client = JamfProClient( 127 | ... server="jamf.my.org", 128 | ... credentials=load_from_aws_secrets_manager( 129 | ... provider_type=ApiClientCredentialsProvider, 130 | ... secret_id="arn:aws:secretsmanager:us-west-2:111122223333:secret:aes128-1a2b3c" 131 | ... ) 132 | ... ) 133 | 134 | Loading from Keychain 135 | ^^^^^^^^^^^^^^^^^^^^^ 136 | 137 | .. important:: 138 | 139 | This utility requires the ``keyring`` extra dependency, which can be installed via: 140 | 141 | .. code-block:: console 142 | 143 | % python3 -m pip install 'jamf-pro-sdk[macOS]' 144 | 145 | When using :class:`~jamf_pro_sdk.clients.auth.ApiClientCredentialsProvider`, the SDK expects: 146 | 147 | - The API **client ID** to be stored in the keychain under your Jamf Pro server name (as the *service_name*) with the client ID as the *username*, and its associated secret as the *password*. 148 | 149 | .. image:: ../_static/api-keychain.png 150 | :alt: Example macOS Keychain entry for API credentials (client_id) 151 | :align: center 152 | :width: 400px 153 | 154 | When using :class:`~jamf_pro_sdk.clients.auth.UserCredentialsProvider`, the SDK expects: 155 | 156 | - A **username** to be passed, and the password to be retrieved from the keychain under the same server name and username. 157 | 158 | .. image:: ../_static/user-keychain.png 159 | :alt: Example keychain entry for User credentials 160 | :align: center 161 | :width: 400px 162 | 163 | .. note:: 164 | 165 | The ``server`` argument should not include the :ref:`scheme `. The SDK normalizes this internally. 166 | 167 | Use the appropriate keyword argument depending on the credential provider class: 168 | 169 | - Use ``client_id=`` when using :class:`~jamf_pro_sdk.clients.auth.ApiClientCredentialsProvider`. 170 | - Use ``username=`` when using :class:`~jamf_pro_sdk.clients.auth.UserCredentialsProvider`. 171 | 172 | .. code-block:: python 173 | 174 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider, load_from_keychain 175 | >>> client = JamfProClient( 176 | ... server="jamf.my.org", 177 | ... credentials=load_from_keychain( 178 | ... provider_type=ApiClientCredentialsProvider, 179 | ... server="jamf.my.org", 180 | ... client_id="" # Required keyword 181 | ... ) 182 | ... ) 183 | 184 | .. code-block:: python 185 | 186 | >>> from jamf_pro_sdk import JamfProClient, UserCredentialsProvider, load_from_keychain 187 | >>> client = JamfProClient( 188 | ... server="jamf.my.org", 189 | ... credentials=load_from_keychain( 190 | ... provider_type=UserCredentialsProvider, 191 | ... server="jamf.my.org", 192 | ... username="" # Required keyword 193 | ... ) 194 | ... ) 195 | 196 | .. tip:: 197 | 198 | You can manage entries using the **Keychain Access** app on macOS. See: `Apple's Keychain User Guide `_. 199 | 200 | 201 | Access Tokens 202 | ------------- 203 | 204 | On the first request made the client will retrieve and cache an access token. This token will be used for all requests up until it nears expiration. At that point the client will refresh the token. If the token has expired, the client will use the configured credentials provider to request a new one. 205 | 206 | You can retrieve the current token at any time: 207 | 208 | .. code-block:: python 209 | 210 | >>> access_token = client.get_access_token() 211 | >>> access_token 212 | AccessToken(type='user', token='eyJhbGciOiJIUzI1NiJ9...', expires=datetime.datetime(2023, 8, 21, 16, 57, 1, 113000, tzinfo=datetime.timezone.utc), scope=None) 213 | >>> access_token.token 214 | 'eyJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkLWFwcCI6IkdFTkVSSUMiLCJhdXRoZW50aWNhdGlvbi10eXBlIjoiSlNTIiwiZ3JvdXBzIjpbXSwic3ViamVjdC10eXBlIjoiSlNTX1VTRVJfSUQiLCJ0b2tlbi11dWlkIjoiM2Y4YzhmY2MtN2U1Ny00Njg5LThiOTItY2UzMTIxYjVlYTY5IiwibGRhcC1zZXJ2ZXItaWQiOi0xLCJzdWIiOiIyIiwiZXhwIjoxNTk1NDIxMDAwfQ.6T9VLA0ABoFO9cqGfp3vWmqllsp3zAbtIW0-M-M41-E' 215 | >>> 216 | 217 | Both the Classic and Pro APIs are exposed through two interfaces: 218 | 219 | .. code-block:: python 220 | 221 | >>> client.classic_api 222 | 223 | >>> client.pro_api 224 | 225 | >>> 226 | 227 | Continue on to :doc:`/user/classic_api` or the :doc:`/user/pro_api`. 228 | 229 | Configuring the Client 230 | ---------------------- 231 | 232 | Some aspects of the Jamf Pro client can be configured at instantiation. These include TLS verification, request timeouts, retries, and pool sizes. Below is the ``SessionConfig`` object used to customize these settings: 233 | 234 | .. autopydantic_model:: jamf_pro_sdk.models.client.SessionConfig 235 | :members: false 236 | 237 | .. note:: 238 | 239 | The ``max_concurrency`` setting is used with the SDK's concurrency features. Those are covered in :ref:`user/advanced:Performing Concurrent Operations`. 240 | 241 | The Jamf Developer Guide states in scalability best practices to not exceed 5 concurrent 242 | connections. Read more about scalability with the Jamf Pro APIs 243 | `here `_. 244 | 245 | The Jamf Pro client will create a default configuration if one is not provided. 246 | 247 | .. code-block:: python 248 | 249 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider, SessionConfig 250 | >>> config = SessionConfig() 251 | >>> config 252 | SessionConfig(timeout=None, max_retries=0, max_concurrency=5, verify=True, cookie=None, ca_cert_bundle=None, scheme='https') 253 | >>> 254 | 255 | Here are two examples on how to use a ``SessionConfig`` with the client to disable TLS verification and set a 30 second timeout: 256 | 257 | .. code-block:: python 258 | 259 | >>> config = SessionConfig() 260 | >>> config.verify = False 261 | >>> config.timeout = 30 262 | >>> config 263 | SessionConfig(timeout=30, max_retries=0, max_concurrency=5, verify=False, cookie=None, ca_cert_bundle=None, scheme='https') 264 | >>> client = JamfProClient( 265 | ... server="jamf.my.org", 266 | ... credentials=ApiClientCredentialsProvider("client_id", "client_secret"), 267 | ... session_config=config, 268 | ... ) 269 | >>> 270 | 271 | >>> config = SessionConfig(**{"verify": False, "timeout": 30}) 272 | >>> config 273 | SessionConfig(timeout=30, max_retries=0, max_concurrency=5, verify=False, cookie=None, ca_cert_bundle=None, scheme='https') 274 | >>> client = JamfProClient( 275 | ... server="jamf.my.org", 276 | ... credentials=ApiClientCredentialsProvider("client_id", "client_secret"), 277 | ... session_config=config, 278 | ... ) 279 | >>> 280 | 281 | .. warning:: 282 | 283 | It is strongly recommended you do not disable TLS certificate verification. 284 | 285 | Logging 286 | ------- 287 | 288 | You can quickly setup console logging using the provided :func:`~jamf_pro_sdk.helpers.logger_quick_setup` function. 289 | 290 | .. code-block:: python 291 | 292 | >>> import logging 293 | >>> from jamf_pro_sdk.helpers import logger_quick_setup 294 | >>> logger_quick_setup(level=logging.DEBUG) 295 | 296 | When set to ``DEBUG`` the stream handler and level will also be applied to ``urllib3``'s logger. All logs will appear 297 | 298 | If you require different handlers or formatting you may configure the SDK's logger manually. 299 | 300 | .. code-block:: python 301 | 302 | >>> import logging 303 | >>> sdk_logger = logging.getLogger("jamf_pro_sdk") 304 | -------------------------------------------------------------------------------- /docs/user/index.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | getting_started 8 | classic_api 9 | pro_api 10 | jcds2 11 | advanced 12 | -------------------------------------------------------------------------------- /docs/user/jcds2.rst: -------------------------------------------------------------------------------- 1 | JCDS2 2 | ===== 3 | 4 | The JCDS2 client provides interfaces for uploading and downloading files to the Jamf Cloud Distribution Service v2 (JCDS2, or just referred to as JCDS). 5 | 6 | Installation Requirements 7 | ------------------------- 8 | 9 | For file uploads the JCDS2 client requires additional dependencies not included with the default installation of the SDK. 10 | 11 | .. code-block:: console 12 | 13 | (.venv) % pip install 'jamf-pro-sdk[aws]' 14 | 15 | File downloads do not require any additional dependencies and can be used with the base install. 16 | 17 | About Packages, Files, and Distribution Points 18 | ---------------------------------------------- 19 | 20 | There are two separate objects across the Classic and Pro APIs for managing packages that can be deployed. 21 | 22 | * **Package** objects are only available through the Classic API. 23 | * **File** objects are only available through the Pro API and specifically are only relevant to the JCDS. 24 | 25 | Packages and files are associated to each other in Jamf Pro, but it is not required. Consider these cases: 26 | 27 | * In order for a file to be included with a policy it **must** have a package object. 28 | * Installing a package using an MDM command **does not** require a package object. 29 | 30 | .. caution:: 31 | 32 | It is important to note that a Jamf Pro package can be created using the API and provided the associated filename without performing any upload through the GUI or APIs, and a JCDS file can be uploaded without being associated with a Jamf Pro package. 33 | 34 | Conversely, deleting a package object that is associated to a JCDS file will delete the file, but deleting a JCDS file associated to a package does not delete the package. 35 | 36 | .. tip:: 37 | 38 | If you only use the JCDS for package distribution in your environment you may skip the rest of this section. 39 | 40 | In environments where multiple types of distribution points exists your file management may vary. The JCDS is often the default, and only, distribution point for **Jamf Cloud** customers. 41 | 42 | Local file distribution points (such as servers/hosts serving traffic over an office network) are managed independently of Jamf Pro. 43 | 44 | Cloud Distribution points (Akami, AWS, and Rackspace) support file uploads from the Jamf Pro web GUI when creating packages, but not through any supported API. 45 | 46 | The JCDS2 client can only manage files that in the JCDS. If you use other types of file distribution points in your environment you will need to handle the upload operations but can use the SDK to create the package objects with :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.create_package`. 47 | 48 | Uploading Files 49 | --------------- 50 | 51 | The file upload operation performs multiple API requests in sequence. 52 | 53 | * All packages in Jamf Pro will be read and checked to see if the filename is already in use by a package (whether or not the file is in the JCDS or doesn't exist in any distribution point). 54 | * A new JCDS file will be created returning temporary IAM credentials to the client. 55 | * If the file is less than 1 GiB in size the upload will be performed in a single request. 56 | * If the file is greater than 1 GiB in size a multipart upload operation will be performed. 57 | * The package object will be created for the file upon success. 58 | 59 | .. code-block:: python 60 | 61 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 62 | >>> client = JamfProClient("dummy.jamfcloud.com", ApiClientCredentialsProvider("client_id", "client_secret")) 63 | >>> client.jcds2.upload_file(file_path="/path/to/my.pkg") 64 | >>> 65 | 66 | Downloading Files 67 | ----------------- 68 | 69 | File downloads will retrieve the download URL to the requested JCDS file and then save it to the path provided. If the path is a directory the filename is appended automatically. 70 | 71 | .. code-block:: python 72 | 73 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 74 | >>> client = JamfProClient("dummy.jamfcloud.com", ApiClientCredentialsProvider("client_id", "client_secret")) 75 | >>> client.jcds2.download_file(file_name="/path/to/my.pkg", download_path="/path/to/downloads/") 76 | >>> 77 | 78 | Download operations retrieve the file in 20 MiB chunks using range requests. The SDK is able to handle extremely large file sizes in this way. 79 | -------------------------------------------------------------------------------- /docs/user/pro_api.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 3 2 | 3 | Pro API 4 | ======= 5 | 6 | The Pro API only returns and accepts JSON data. API responses are returned as data models that can be interacted with using dot notation. 7 | 8 | Pagination 9 | ---------- 10 | 11 | Some Pro API read operations support pagination. The API will return a partial response if there are more results than the set page size of the request. To obtain more results the next page must be requested. 12 | 13 | The SDK uses a :class:`~jamf_pro_sdk.clients.pro_api.pagination.Paginator` to automatically handle this. If the total number of results in the first request are greater than the page size the paginator will fetch all of the remaining pages concurrently. 14 | 15 | .. tip:: 16 | 17 | If you have large number of records in a paginated request, you may find that using a smaller page size is *faster* than setting a larger (or max) page size. 18 | 19 | Paginators can either return the full response of all pages at once, or it can return an iterator to yield the page objects. 20 | 21 | The curated methods will return all results by default. Each operation that supports pagination allows this behavior to be overridden to return the generator. 22 | 23 | .. code-block:: python 24 | 25 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 26 | >>> client = JamfProClient("dummy.jamfcloud.com", ApiClientCredentialsProvider("client_id", "client_secret")) 27 | 28 | >>> response = client.pro_api.get_computer_inventory_v1() 29 | >>> response 30 | [Computer(id='117', udid='a311b7c8-75ee-48cf-9b1b-a8598f013366', general=ComputerGeneral(name='Backancient',... 31 | >>> for r in response: 32 | ... # Interact with return 33 | ... pass 34 | ... 35 | >>> 36 | 37 | >>> response = client.pro_api.get_computer_inventory_v1(return_generator=True) 38 | >>> response 39 | 40 | >>> for page in response: 41 | ... # Interact with the page 42 | ... for r in page.results: 43 | ... # Interact with return 44 | ... pass 45 | ... 46 | >>> 47 | 48 | The paginator object itself will return the generator by default. This can be overridden in much the same way. 49 | 50 | .. code-block:: python 51 | 52 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 53 | >>> from jamf_pro_sdk.clients.pro_api.pagination import Paginator 54 | >>> from jamf_pro_sdk.models.pro.computers import Computer 55 | >>> client = JamfProClient("dummy.jamfcloud.com", ApiClientCredentialsProvider("client_id", "client_secret")) 56 | 57 | >>> paginator = Paginator(api_client=client.pro_api, resource_path="v1/computers-inventory", return_model=Computer) 58 | >>> paginator() 59 | 60 | 61 | >>> paginator(return_generator=False) 62 | [Computer(id='117', udid='a311b7c8-75ee-48cf-9b1b-a8598f013366', general=ComputerGeneral(name='Backancient',... 63 | 64 | Many paginated API read operations also support query parameters to filter and sort the results so you can reduce the number of items returned in a request. 65 | 66 | The SDK provides programmatic interfaces for both of these options that will properly construct the expressions. 67 | 68 | .. _Pro API Filtering: 69 | 70 | Filtering 71 | ^^^^^^^^^ 72 | 73 | A filter expression is returned from a ``FilterField`` object when one of the operators is called. 74 | 75 | The example below creates a filter expression requiring the ID of every result must be equal to ``1``. 76 | 77 | .. code-block:: python 78 | 79 | >>> from jamf_pro_sdk.clients.pro_api.pagination import FilterField 80 | >>> filter_expression = FilterField("id").eq(1) 81 | >>> print(filter_expression) 82 | id==1 83 | >>> 84 | 85 | Filters can be combined together using Python's ``&`` and ``|`` binary operands. 86 | 87 | The example below creates a filter expression requiring the ID of every result is below ``100`` and the value of the asset tag is below ``1000``. 88 | 89 | .. code-block:: python 90 | 91 | >>> from jamf_pro_sdk.clients.pro_api.pagination import FilterField 92 | >>> filter_expression = FilterField("id").gt(100) & FilterField("general.assetTag").lt(1000) 93 | >>> print(filter_expression) 94 | id>100;general.assetTag<1000 95 | >>> 96 | 97 | ``AND`` operators take precedence and are evaluated before any ``OR`` operators. Filters can be grouped together and the result of the inner filters will be evaluated in order with the outer filters. 98 | 99 | The example below creates a filter expression requiring either the barcode or the asset tag of every result to be below ``1000`` and the ID be below ``100``. 100 | 101 | .. code-block:: python 102 | 103 | >>> from jamf_pro_sdk.clients.pro_api.pagination import FilterField, filter_group 104 | >>> filter_expression = filter_group(FilterField("general.barcode1").lt(1000) | FilterField("general.assetTag").lt(1000)) & FilterField("id").gte(100) 105 | >>> print(filter_expression) 106 | (general.barcode1<1000,general.assetTag<1000);id>=100 107 | >>> 108 | 109 | .. _Pro API Sorting: 110 | 111 | Sorting 112 | ^^^^^^^ 113 | 114 | Sorting expressions work similarly. A sort expression is returned from a ``SortField`` object when one of the operators is called. 115 | 116 | .. code-block:: python 117 | 118 | >>> from jamf_pro_sdk.clients.pro_api.pagination import SortField 119 | >>> sort_expression = SortField("id").asc() 120 | >>> print(sort_expression) 121 | id:asc 122 | >>> 123 | 124 | Sortable fields can be combined together using Python's ``&`` binary operands. Unlike filter, sort fields are evaluated from left-to-right. The leftmost field will be the first sort, and then the next, and so on. 125 | 126 | .. code-block:: python 127 | 128 | >>> from jamf_pro_sdk.clients.pro_api.pagination import SortField 129 | >>> sort_expression = SortField("name").asc() & SortField("id").desc() 130 | >>> print(sort_expression) 131 | name:asc,id:desc 132 | >>> 133 | 134 | Full Example 135 | ^^^^^^^^^^^^ 136 | 137 | Here is an example of a paginated request using the SDK with the sorting and filtering options. 138 | 139 | .. code-block:: python 140 | 141 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 142 | >>> from jamf_pro_sdk.clients.pro_api.pagination import FilterField, SortField 143 | 144 | >>> client = JamfProClient( 145 | ... server="dummy.jamfcloud.com", 146 | ... credentials=ApiClientCredentialsProvider("client_id", "client_secret") 147 | ... ) 148 | >>> 149 | 150 | >>> response = client.pro_api.get_computer_inventory_v1( 151 | ... sections=["GENERAL", "USER_AND_LOCATION", "OPERATING_SYSTEM"], 152 | ... page_size=1000, 153 | ... sort_expression=SortField("id").asc(), 154 | ... filter_expression=FilterField("operatingSystem.version").lt("13.") 155 | ... ) 156 | >>> 157 | 158 | MDM Commands 159 | ------------ 160 | 161 | The SDK provides MDM commands in the form of models that are passed to the :meth:`~jamf_pro_sdk.clients.pro_api.ProApi.send_mdm_command_preview` method. 162 | 163 | .. code-block:: python 164 | 165 | >>> from jamf_pro_sdk import JamfProClient, ApiClientCredentialsProvider 166 | >>> from jamf_pro_sdk.models.pro.mdm import LogOutUserCommand 167 | >>> client = JamfProClient("dummy.jamfcloud.com", ApiClientCredentialsProvider("client_id", "client_secret")) 168 | >>> response client.pro_api.send_mdm_command_preview( 169 | ... management_ids=["4eecc1fb-f52d-48c5-9560-c246b23601d3"], 170 | ... command=LogOutUserCommand() 171 | ... ) 172 | 173 | The ``response`` will contain an array of :class:`~jamf_pro_sdk.models.pro.mdm.SendMdmCommandResponse` objects that have the IDs of the commands sent. Those IDs can be used with the ``uuid`` filter of :meth:`~jamf_pro_sdk.clients.pro_api.ProApi.get_mdm_commands_v2` to get the command's status. 174 | 175 | Basic MDM commands with no additional properties can be passed as instantiated objects as shown above with the ``LogOutUserCommand`` command. For other commands the additional properties can be set after instantiation, or a dictionary of values can be unpacked. 176 | 177 | .. code-block:: python 178 | 179 | >>> from jamf_pro_sdk.models.pro.mdm import EraseDeviceCommand 180 | 181 | >>> command = EraseDeviceCommand() 182 | >>> command.pin = "123456" 183 | 184 | >>> command = EraseDeviceCommand(**{"pin": "123456"}) 185 | 186 | Commands with required properties must have those values passed at instantiation. 187 | 188 | .. code-block:: python 189 | 190 | >>> from jamf_pro_sdk.models.pro.mdm import EnableLostModeCommand 191 | 192 | >>> command = EnableLostModeCommand() 193 | Traceback (most recent call last): 194 | File "", line 1, in 195 | File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__ 196 | pydantic.error_wrappers.ValidationError: 3 validation errors for EnableLostModeCommand 197 | lostModeMessage 198 | field required (type=value_error.missing) 199 | lostModePhone 200 | field required (type=value_error.missing) 201 | lostModeFootnote 202 | field required (type=value_error.missing) 203 | 204 | >>> command = EnableLostModeCommand( 205 | ... lostModeMessage="Please return me to my owner.", 206 | ... lostModePhone="123-456-7890", 207 | ... lostModeFootnote="No reward." 208 | ... ) 209 | >>> 210 | 211 | Read the documentation for :ref:`MDM Command Models` for all support MDM commands and their properties. 212 | -------------------------------------------------------------------------------- /docs/webhooks/index.rst: -------------------------------------------------------------------------------- 1 | Webhooks 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | webhooks_client 8 | webhook_models 9 | webhook_receiver 10 | -------------------------------------------------------------------------------- /docs/webhooks/webhook_models.rst: -------------------------------------------------------------------------------- 1 | Webhook Event Validation 2 | ======================== 3 | 4 | The Jamf Pro SDK includes Pydantic models for validating JSON webhook events. These models can be imported and used without instantiating the Jamf Pro client. 5 | 6 | .. note:: 7 | 8 | XML webhooks are not supported by the SDK. 9 | 10 | Take this example ``ComputerAdded`` event: 11 | 12 | .. code-block:: json 13 | 14 | { 15 | "webhook": { 16 | "eventTimestamp": 1685313891, 17 | "id": 1, 18 | "name": "New Computer Webhook", 19 | "webhookEvent": "ComputerAdded" 20 | }, 21 | "event": { 22 | "alternateMacAddress": "00:67:31:80:18:80", 23 | "building": "", 24 | "department": "", 25 | "deviceName": "Zach's MacBook Pro", 26 | "emailAddress": "zach@example.org", 27 | "ipAddress": "118.36.18.147", 28 | "jssID": 2, 29 | "macAddress": "1c:bd:27:12:ad:6a", 30 | "model": "MacBookPro18,3", 31 | "osBuild": "22E772610a", 32 | "osVersion": "13.3.1 (a)", 33 | "phone": "690.741.7883x61304", 34 | "position": "Founder", 35 | "realName": "Zach", 36 | "reportedIpAddress": "188.51.118.97", 37 | "room": "", 38 | "serialNumber": "MSMDVUC1HB", 39 | "udid": "B6AC3528-474D-48A1-887C-7ABA3661C226", 40 | "userDirectoryID": "", 41 | "username": "zach" 42 | } 43 | } 44 | 45 | It can be validated using the same name model, and then the fields accessed through dot notation (the same as the response objects from the Jamf Pro client). 46 | 47 | >>> import json 48 | >>> from jamf_pro_sdk.models.webhooks import ComputerAdded 49 | >>> event_data = json.loads(event_body) 50 | >>> validated_event = ComputerAdded(**event_data) 51 | >>> validated_event.webhook 52 | ComputerAddedWebhook(eventTimestamp=1685313891, id=1, name='New Computer Webhook', webhookEvent='ComputerAdded') 53 | >>> validated_event.event.ipAddress 54 | IPv4Address('118.36.18.147') 55 | >>> 56 | 57 | -------------------------------------------------------------------------------- /docs/webhooks/webhook_receiver.rst: -------------------------------------------------------------------------------- 1 | Webhook Receiver Example using FastAPI 2 | ====================================== 3 | 4 | The SDK's webhook models use Pydantic which is compatible with FastAPI for request validation. 5 | 6 | Installation Requirements 7 | ------------------------- 8 | 9 | .. code-block:: console 10 | 11 | (.venv) % pip install fastapi 'uvicorn[standard]' 12 | 13 | The App 14 | ------- 15 | 16 | Create a file called ``main.py`` and copy the contents below. 17 | 18 | This is a basic webhook receiver that only processes mobile device enrollment events. There is a single route at the root of the server ``/`` that will accept HTTP POST requests with JSON bodies. JSON is the default input/output for FastAPI apps and there is no extra configuration needed. The use of ``typing.Union`` allows multiple models to be accepted for the route. 19 | 20 | .. code-block:: python 21 | 22 | from typing import Union 23 | 24 | from fastapi import FastAPI 25 | from jamf_pro_sdk.models.webhooks import MobileDeviceEnrolled, MobileDeviceUnEnrolled 26 | 27 | app = FastAPI() 28 | 29 | AllowedWebhooks = Union[MobileDeviceEnrolled, MobileDeviceUnEnrolled] 30 | 31 | 32 | @app.post("/") 33 | def receive_webhook(webhook: AllowedWebhooks): 34 | print(f"Webhook received for device {webhook.event.udid}") 35 | 36 | 37 | To learn more about FastAPI read the `First Steps `_ tutorial. 38 | 39 | Run the App 40 | ----------- 41 | 42 | The ``uvicorn`` command will run the server. Using ``0.0.0.0`` will allow access using the IP address of the host running the app. To only test locally use ``127.0.0.1``. 43 | 44 | .. code-block:: console 45 | 46 | (.venv) % uvicorn main:app --host 0.0.0.0 --port 80 47 | INFO: Started server process [71338] 48 | INFO: Waiting for application startup. 49 | INFO: Application startup complete. 50 | INFO: Uvicorn running on http://0.0.0.0:80 (Press CTRL+C to quit) 51 | 52 | Test Using Webhooks Client 53 | -------------------------- 54 | 55 | Now that the receiver is running you can send test events using the wehooks client. 56 | 57 | >>> from jamf_pro_sdk.clients.webhooks import WebhooksClient 58 | >>> from jamf_pro_sdk.models.webhooks import ComputerAdded, MobileDeviceEnrolled 59 | >>> client = WebhooksClient("http://0.0.0.0/") 60 | >>> client.fire(MobileDeviceEnrolled) 61 | >>> client.fire(ComputerAdded) 62 | 63 | In the logs for the receiver you will see the requests being processed. 64 | 65 | .. code-block:: console 66 | 67 | Webhook received for device 6A331227-7BC9-44CD-901F-D719F460CE21 68 | INFO: 127.0.0.1:63139 - "POST / HTTP/1.1" 200 OK 69 | INFO: 127.0.0.1:63139 - "POST / HTTP/1.1" 422 Unprocessable Entity 70 | 71 | Note that the ``ComputerAdded`` event was rejected. Use of models for validation will ensure only allowed and correctly formatted webhook events are processed by the server. 72 | -------------------------------------------------------------------------------- /docs/webhooks/webhooks_client.rst: -------------------------------------------------------------------------------- 1 | Basic Usage: Webhooks Client 2 | ============================ 3 | 4 | The webhooks client allows developers to send test events with randomized data to their applications. These events are derived from the webhook models in the SDK. 5 | 6 | Installation Requirements 7 | ------------------------- 8 | 9 | The webhook generator requires additional dependencies not included with the default installation of the SDK. 10 | 11 | .. code-block:: console 12 | 13 | (.venv) % pip install 'jamf-pro-sdk[webhooks]' 14 | 15 | Basic Usage 16 | ----------- 17 | 18 | The webhooks client provides an interface to create generator classes to create mock data. Generators are wrappers around webhook models. 19 | 20 | >>> from jamf_pro_sdk.clients.webhooks import get_webhook_generator 21 | >>> from jamf_pro_sdk.models.webhooks import MobileDeviceEnrolled 22 | >>> mobile_device_enrolled_generator = get_webhook_generator(MobileDeviceEnrolled) 23 | >>> mobile_device_enrolled_generator 24 | 25 | >>> mobile_device_1 = mobile_device_enrolled_generator.build() 26 | >>> mobile_device_1.webhook 27 | MobileDeviceEnrolledWebhook(eventTimestamp=1685331210, id=1031, name='JYksqLwBskpOmWTXLVJZ', webhookEvent='MobileDeviceEnrolled') 28 | >>> 29 | 30 | Each time ``build()`` is used with a generator a unique webhook object is returned. These can be passed to the webhooks client and sent to a remote host mocking an actual Jamf Pro event. 31 | 32 | >>> from jamf_pro_sdk.clients.webhooks import WebhooksClient, get_webhook_generator 33 | >>> from jamf_pro_sdk.models.webhooks import ComputerCheckIn 34 | >>> computer_checkin_generator = get_webhook_generator(ComputerCheckIn) 35 | >>> client = WebhooksClient("http://localhost/post") 36 | >>> for _ in range(3): 37 | ... client.send_webhook(computer_checkin_generator.build()) 38 | ... 39 | 40 | 41 | 42 | >>> 43 | 44 | The client also has a ``fire()`` method that can send many events rapidly. The webhooks client accepts a model type as an argument to the ``fire()`` method and will create a generator internally. A unique JSON payload will then be sent to provided URL using HTTP POST. 45 | 46 | >>> from jamf_pro_sdk.clients.webhooks import WebhooksClient 47 | >>> client = WebhooksClient("http://localhost/post") 48 | >>> list(client.fire(MobileDeviceEnrolled, 10)) 49 | [, , , , , , , , , ] 50 | >>> 51 | 52 | The client automatically handles concurrency with a default of 10 threads. Passing a counter higher than 1 will automatically multi-thread requests. Each HTTP POST will contain a unique JSON payload. Note that ``fire()`` returns an iterator. 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "jamf-pro-sdk" 3 | dynamic = ["readme", "version"] 4 | description = "Jamf Pro SDK for Python" 5 | keywords = ["jamf", "pro", "jss", "jps"] 6 | license = {text = "MIT"} 7 | requires-python = ">=3.9, <4" 8 | dependencies = [ 9 | "requests>=2.28.1,<3", 10 | "pydantic>=2,<3", 11 | "dicttoxml>=1.7.16,<2", 12 | "defusedxml" 13 | ] 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | "Intended Audience :: Developers", 17 | "Topic :: Software Development :: Libraries", 18 | "License :: OSI Approved :: MIT No Attribution License (MIT-0)", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | 26 | 27 | [project.urls] 28 | Documentation = "https://macadmins.github.io/jamf-pro-sdk-python" 29 | Source = "https://github.com/macadmins/jamf-pro-sdk-python" 30 | Changelog = "https://github.com/macadmins/jamf-pro-sdk-python/blob/main/CHANGELOG.md" 31 | 32 | 33 | [project.optional-dependencies] 34 | aws = [ 35 | "boto3>=1.26.45,<2" 36 | ] 37 | macOS = [ 38 | "keyring>=23.13.1" 39 | ] 40 | webhooks = [ 41 | "polyfactory>=2.1.1,<3" 42 | ] 43 | dev = [ 44 | "boto3>=1.26.45,<2", 45 | "keyring>=23.13.1", 46 | "polyfactory>=2.1.1,<3", 47 | "ruff", 48 | "coverage[toml]", 49 | "pytest >= 6", 50 | "pytest-cov", 51 | "deepdiff", 52 | "sphinx", 53 | "autodoc-pydantic", 54 | "furo", 55 | "build" 56 | ] 57 | 58 | 59 | [build-system] 60 | requires = [ 61 | "setuptools >= 61", 62 | "wheel", 63 | ] 64 | build-backend = "setuptools.build_meta" 65 | 66 | 67 | [tool.setuptools] 68 | package-dir = {"" = "src"} 69 | 70 | 71 | [tool.setuptools.packages.find] 72 | where = ["src"] 73 | 74 | 75 | [tool.setuptools.package-data] 76 | "jamf_pro_sdk" = ["py.typed"] 77 | 78 | 79 | [tool.setuptools.dynamic] 80 | version = {attr = "jamf_pro_sdk.__about__.__version__"} 81 | readme = {file = ["README.md"], content-type = "text/markdown"} 82 | 83 | 84 | [tool.ruff] 85 | line-length = 100 86 | target-version = "py39" 87 | src = [ 88 | "src", 89 | "tests" 90 | ] 91 | 92 | [tool.ruff.format] 93 | quote-style = "double" 94 | indent-style = "space" 95 | skip-magic-trailing-comma = false 96 | line-ending = "auto" 97 | docstring-code-format = false 98 | docstring-code-line-length = "dynamic" 99 | 100 | [tool.ruff.lint] 101 | select = [ 102 | "E101", 103 | "F401", 104 | "F403", 105 | "I001", 106 | "N801", 107 | "N802", 108 | "N806" 109 | ] 110 | per-file-ignores = {"__init__.py" = ["F401"]} 111 | 112 | 113 | [tool.pytest.ini_options] 114 | minversion = "6.0" 115 | addopts = [ 116 | "--durations=5", 117 | "--color=yes", 118 | "--cov=src", 119 | "--cov-report=html:coverage/htmlcov", 120 | "--cov-report=term-missing", 121 | # "--cov-fail-under=90", 122 | ] 123 | testpaths = [ "./tests" ] 124 | 125 | 126 | #[tool.coverage.run] 127 | #source = ["src", "jamf_pro_sdk"] 128 | #branch = true 129 | #parallel = true 130 | 131 | 132 | #[tool.coverage.report] 133 | #show_missing = true 134 | # Uncomment the following line to fail to build when the coverage is too low 135 | # fail_under = 99 136 | 137 | #[tool.coverage.xml] 138 | #output = "coverage/coverage.xml" 139 | 140 | #[tool.coverage.html] 141 | #directory = "coverage/htmlcov" 142 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/__about__.py: -------------------------------------------------------------------------------- 1 | __title__ = "jamf-pro-sdk" 2 | __version__ = "0.8a1" 3 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | from .__about__ import __title__, __version__ 2 | from .clients import JamfProClient 3 | from .clients.auth import ( 4 | ApiClientCredentialsProvider, 5 | UserCredentialsProvider, 6 | load_from_aws_secrets_manager, 7 | load_from_keychain, 8 | prompt_for_credentials, 9 | ) 10 | from .helpers import logger_quick_setup 11 | from .models.client import SessionConfig 12 | 13 | __all__ = [ 14 | "__title__", 15 | "__version__", 16 | "JamfProClient", 17 | "ApiClientCredentialsProvider", 18 | "UserCredentialsProvider", 19 | "load_from_aws_secrets_manager", 20 | "load_from_keychain", 21 | "prompt_for_credentials", 22 | "logger_quick_setup", 23 | "SessionConfig", 24 | ] 25 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/clients/jcds2.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import math 5 | from pathlib import Path 6 | from tempfile import TemporaryDirectory 7 | from typing import TYPE_CHECKING, Callable, Iterator, Union 8 | 9 | import requests 10 | from requests.adapters import HTTPAdapter 11 | 12 | try: 13 | import boto3 14 | except ImportError: 15 | BOTO3_IS_INSTALLED = False 16 | boto3 = None 17 | else: 18 | BOTO3_IS_INSTALLED = True 19 | 20 | from ..models.classic.packages import ClassicPackage 21 | from ..models.pro.jcds2 import NewFile 22 | 23 | if TYPE_CHECKING: 24 | from .classic_api import ClassicApi 25 | from .pro_api import ProApi 26 | 27 | CHUNK_SIZE = 1024 * 1024 * 20 # 20 MB 28 | 29 | logger = logging.getLogger("jamf_pro_sdk") 30 | 31 | 32 | class JCDS2FileExistsError(Exception): 33 | """The file already exists in Jamf Pro associated to a package.""" 34 | 35 | 36 | class JCDS2FileNotFoundError(Exception): 37 | """The requested file does not exist in the JCDS.""" 38 | 39 | 40 | class FileUpload: 41 | """Represents a file that will be uploaded to the JCDS.""" 42 | 43 | def __init__(self, path: Path): 44 | self.path = path 45 | self.size = path.stat().st_size 46 | self.total_chunks = math.ceil(self.size / CHUNK_SIZE) 47 | 48 | def get_chunk(self, chunk_number: int) -> bytes: 49 | """Returns a range of bytes for a given chunk (index).""" 50 | if chunk_number > self.total_chunks: 51 | raise ValueError(f"Chunk number must be less than or equal to {self.total_chunks}") 52 | 53 | with open(self.path, "rb") as fobj: 54 | fobj.seek(chunk_number * CHUNK_SIZE) 55 | return fobj.read(CHUNK_SIZE) 56 | 57 | 58 | class JCDS2: 59 | """Provides an interface to manage files in JCDS2.""" 60 | 61 | def __init__( 62 | self, 63 | classic_api_client: ClassicApi, 64 | pro_api_client: ProApi, 65 | concurrent_requests_method: Callable[..., Iterator], 66 | ): 67 | self.classic_api_client = classic_api_client 68 | self.pro_api_client = pro_api_client 69 | self.concurrent_api_requests = concurrent_requests_method 70 | 71 | @staticmethod 72 | def _upload_file(s3_client, jcds_file: NewFile, file_upload: FileUpload): 73 | logger.info("JCDS2-Upload %s ", file_upload.path) 74 | with open(file_upload.path, "rb") as fobj: 75 | resp = s3_client.put_object( 76 | Body=fobj, 77 | Bucket=jcds_file.bucketName, 78 | Key=f"{jcds_file.path}{file_upload.path.name}", 79 | ) 80 | logger.debug(resp) 81 | 82 | def _upload_multipart(self, s3_client, jcds_file: NewFile, file_upload: FileUpload): 83 | logger.info("JCDS2-UploadMultipart %s", file_upload.path) 84 | multipart_upload = s3_client.create_multipart_upload( 85 | Bucket=jcds_file.bucketName, Key=f"{jcds_file.path}{file_upload.path.name}" 86 | ) 87 | logger.debug(multipart_upload) 88 | 89 | multipart_upload_parts = list() 90 | 91 | for r in self.concurrent_api_requests( 92 | handler=self._upload_part, 93 | arguments=[ 94 | { 95 | "s3_client": s3_client, 96 | "part_number": i, 97 | "file_upload": file_upload, 98 | "multipart_upload": multipart_upload, 99 | } 100 | for i in range(1, file_upload.total_chunks + 1) 101 | ], 102 | ): 103 | multipart_upload_parts.append(r) 104 | 105 | try: 106 | multipart_upload_complete = s3_client.complete_multipart_upload( 107 | Bucket=multipart_upload["Bucket"], 108 | Key=multipart_upload["Key"], 109 | MultipartUpload={"Parts": multipart_upload_parts}, 110 | UploadId=multipart_upload["UploadId"], 111 | ) 112 | logger.debug(multipart_upload_complete) 113 | except: 114 | logger.error("JCDS2-UploadMultipart-Aborted %s", file_upload.path) 115 | multipart_aborted = s3_client.abort_multipart_upload( 116 | Bucket=multipart_upload["Bucket"], 117 | Key=multipart_upload["Key"], 118 | UploadId=multipart_upload["UploadId"], 119 | ) 120 | logger.debug(multipart_aborted) 121 | raise 122 | 123 | @staticmethod 124 | def _upload_part(s3_client, multipart_upload: dict, part_number: int, file_upload: FileUpload): 125 | logger.info("JCDS2-UploadMultipart-Part %s %s", part_number, file_upload.path.name) 126 | # TODO: Retry functionality if a part upload fails 127 | part_resp = s3_client.upload_part( 128 | Body=file_upload.get_chunk(part_number - 1), 129 | Bucket=multipart_upload["Bucket"], 130 | Key=multipart_upload["Key"], 131 | PartNumber=part_number, 132 | UploadId=multipart_upload["UploadId"], 133 | ) 134 | logger.debug(part_resp) 135 | return {"PartNumber": part_number, "ETag": part_resp["ETag"]} 136 | 137 | def upload_file(self, file_path: Union[str, Path]) -> None: 138 | """Upload a file to the JCDS and create the package object. 139 | 140 | If the file is less than 1 GiB in size the upload will be performed in a single request. If 141 | the file is greater than 1 GiB in size a multipart upload operation will be performed. 142 | 143 | A ``JCDS2FileExistsError`` is raised if any file of the same name exists and is associated to 144 | a package. 145 | 146 | .. important:: 147 | 148 | This operation requires the ``aws`` extra dependency. 149 | 150 | :param file_path: The path to the file to upload. Will raise ``FileNotFoundError`` if the path 151 | to the file's location does not exist. 152 | :type file_path: Union[str, Path] 153 | """ 154 | if not BOTO3_IS_INSTALLED: 155 | raise ImportError("The 'aws' extra dependency is required.") 156 | 157 | if not isinstance(file_path, Path): 158 | file_path = Path(file_path) 159 | 160 | if not file_path.exists(): 161 | raise FileNotFoundError("A file or directory at the download path already exists") 162 | 163 | file_upload = FileUpload(file_path) 164 | 165 | packages = [ 166 | self.classic_api_client.get_package_by_id(p) 167 | for p in self.classic_api_client.list_all_packages() 168 | ] 169 | 170 | for p in packages: 171 | if file_upload.path.name == p.filename: 172 | raise JCDS2FileExistsError( 173 | f"The file '{file_upload.path.name}' exists and is associated to package ({p.id}) '{p.name}'" 174 | ) 175 | 176 | new_jcds_file = self.pro_api_client.create_jcds_file_v1() 177 | 178 | boto3_session = boto3.Session( 179 | aws_access_key_id=new_jcds_file.accessKeyID, 180 | aws_secret_access_key=new_jcds_file.secretAccessKey, 181 | aws_session_token=new_jcds_file.sessionToken, 182 | region_name=new_jcds_file.region, 183 | ) 184 | 185 | s3_client = boto3_session.client(service_name="s3") 186 | 187 | try: 188 | # if file_upload.size < 5368709120: # 5 GiB 189 | if file_upload.size < 1073741824: # 1 GiB 190 | self._upload_file( 191 | s3_client=s3_client, jcds_file=new_jcds_file, file_upload=file_upload 192 | ) 193 | else: 194 | self._upload_multipart( 195 | s3_client=s3_client, jcds_file=new_jcds_file, file_upload=file_upload 196 | ) 197 | except Exception as err: 198 | logger.exception(err) 199 | raise 200 | else: 201 | new_package = ClassicPackage(name=file_path.name, filename=file_path.name) 202 | new_pkg_resp = self.classic_api_client.create_package(data=new_package) 203 | logger.debug(new_pkg_resp) 204 | 205 | @staticmethod 206 | def _download_range(session: requests.Session, url: str, index: int, temp_dir: str): 207 | range_start = index * CHUNK_SIZE 208 | range_end = ((index + 1) * CHUNK_SIZE) - 1 209 | with session.get( 210 | url, headers={"Range": f"bytes={range_start}-{range_end}"}, timeout=60 211 | ) as range_response: 212 | logger.debug(range_response.headers) 213 | with open(temp_dir + f"/chunk_{str(index).zfill(9)}", "wb") as fobj: 214 | fobj.write(range_response.content) 215 | 216 | def download_file(self, file_name: str, download_path: Union[str, Path]) -> None: 217 | """Download a file from the JCDS by filename. 218 | 219 | :param file_name: The name of the file in the JCDS to download. 220 | :type file_name: str 221 | 222 | :param download_path: The path to download the file to. If the provided path is directory 223 | the file name will be appended to it. Will raise `FileExistsError` if the path is to a 224 | file location that already exists. 225 | :type download_path: Union[str, Path] 226 | """ 227 | if not isinstance(download_path, Path): 228 | download_path = Path(download_path) 229 | 230 | if download_path.is_dir(): 231 | download_path = download_path / file_name 232 | 233 | if download_path.exists(): 234 | raise FileExistsError("A file or directory at the download path already exists") 235 | 236 | try: 237 | download_file = self.pro_api_client.get_jcds_file_v1(file_name=file_name) 238 | except requests.HTTPError as error: 239 | if error.response.status_code == 404: 240 | raise JCDS2FileNotFoundError(f"The file '{file_name}' does not exist") 241 | else: 242 | raise 243 | 244 | # TODO: Retry feature that's not relying on urllib3's implementation 245 | download_session = requests.Session() 246 | download_session.mount( 247 | prefix="https://", 248 | adapter=HTTPAdapter(max_retries=3, pool_connections=5, pool_maxsize=5), 249 | ) 250 | 251 | with download_session.head(download_file.uri) as download_file_head: 252 | total_chunks = math.ceil(int(download_file_head.headers["Content-Length"]) / CHUNK_SIZE) 253 | 254 | temp_dir = TemporaryDirectory(prefix="jcds2-download-") 255 | logger.debug("JCDS2-Download-TempDir %s", temp_dir.name) 256 | 257 | r = self.concurrent_api_requests( 258 | handler=self._download_range, 259 | arguments=[ 260 | { 261 | "session": download_session, 262 | "url": download_file.uri, 263 | "index": i, 264 | "temp_dir": temp_dir.name, 265 | } 266 | for i in range(0, total_chunks) 267 | ], 268 | max_concurrency=5, 269 | ) 270 | list(r) 271 | 272 | with open(download_path, "ab") as fobj: 273 | for chunk in sorted(Path(temp_dir.name).glob("chunk_*")): 274 | with open(chunk, "rb") as chunk_fobj: 275 | fobj.write(chunk_fobj.read()) 276 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/clients/pro_api/pagination.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, Optional, Type, Union 6 | 7 | from pydantic import BaseModel 8 | 9 | if TYPE_CHECKING: 10 | from . import ProApi 11 | 12 | 13 | # QUERY FILTERING 14 | 15 | 16 | @dataclass 17 | class FilterEntry: 18 | name: str 19 | op: str 20 | value: str 21 | 22 | 23 | class FilterExpression: 24 | def __init__(self, filter_expression: str, fields: List[FilterEntry]): 25 | self.filter_expression = filter_expression 26 | self.fields = fields 27 | 28 | def __str__(self): 29 | return self.filter_expression 30 | 31 | def _compose(self, expression: FilterExpression, sep: str): 32 | return FilterExpression( 33 | filter_expression=f"{self}{sep}{expression.filter_expression}", 34 | fields=self.fields + expression.fields, 35 | ) 36 | 37 | def __and__(self, expression: FilterExpression): 38 | return self._compose(expression=expression, sep=";") 39 | 40 | def __or__(self, expression: FilterExpression): 41 | return self._compose(expression=expression, sep=",") 42 | 43 | def validate(self, allowed_fields: List[str]): 44 | if not all([i.name in allowed_fields for i in self.fields]): 45 | raise ValueError( 46 | f"A field is not in allowed filter fields: {', '.join(allowed_fields)}" 47 | ) 48 | 49 | 50 | class FilterField: 51 | def __init__(self, name: str): 52 | self.name = name 53 | 54 | def _return_expression(self, operator: str, value: Union[bool, int, str]) -> FilterExpression: 55 | return FilterExpression( 56 | filter_expression=f"{self.name}{operator}{value}", 57 | fields=[FilterEntry(name=self.name, op=operator, value=str(value))], 58 | ) 59 | 60 | def eq(self, value: Union[bool, int, str]) -> FilterExpression: 61 | return self._return_expression("==", value) 62 | 63 | def ne(self, value: Union[bool, int, str]) -> FilterExpression: 64 | return self._return_expression("!=", value) 65 | 66 | def lt(self, value: Union[bool, int, str]) -> FilterExpression: 67 | return self._return_expression("<", value) 68 | 69 | def lte(self, value: Union[bool, int, str]) -> FilterExpression: 70 | return self._return_expression("<=", value) 71 | 72 | def gt(self, value: Union[bool, int, str]) -> FilterExpression: 73 | return self._return_expression(">", value) 74 | 75 | def gte(self, value: Union[bool, int, str]) -> FilterExpression: 76 | return self._return_expression(">=", value) 77 | 78 | @staticmethod 79 | def _iter_to_str(iterable: Iterable): 80 | return ",".join([str(i) for i in iterable]) 81 | 82 | def is_in(self, value: Iterable[Union[bool, int, str]]) -> FilterExpression: 83 | return self._return_expression("=in=", f"({self._iter_to_str(value)})") 84 | 85 | def not_in(self, value: Iterable[Union[bool, int, str]]) -> FilterExpression: 86 | return self._return_expression("=out=", f"({self._iter_to_str(value)})") 87 | 88 | 89 | def filter_group(expression: FilterExpression) -> FilterExpression: 90 | return FilterExpression( 91 | filter_expression=f"({expression.filter_expression})", 92 | fields=expression.fields, 93 | ) 94 | 95 | 96 | # QUERY SORTING 97 | 98 | 99 | class SortExpression: 100 | def __init__(self, sort_expression: str, fields: List[str]): 101 | self.sort_expression = sort_expression 102 | self.fields = fields 103 | 104 | def __str__(self): 105 | return self.sort_expression 106 | 107 | def __and__(self, expression: SortExpression) -> SortExpression: 108 | return SortExpression( 109 | sort_expression=f"{self},{expression.sort_expression}", 110 | fields=self.fields + expression.fields, 111 | ) 112 | 113 | def validate(self, allowed_fields: List[str]): 114 | if not all([i in allowed_fields for i in self.fields]): 115 | raise ValueError(f"A field is not in allowed sort fields: {', '.join(allowed_fields)}") 116 | 117 | 118 | class SortField: 119 | def __init__(self, field: str): 120 | self.field = field 121 | 122 | def _return_expression(self, order: str) -> SortExpression: 123 | return SortExpression(sort_expression=f"{self.field}:{order}", fields=[self.field]) 124 | 125 | def asc(self) -> SortExpression: 126 | return self._return_expression("asc") 127 | 128 | def desc(self) -> SortExpression: 129 | return self._return_expression("desc") 130 | 131 | 132 | # PAGINATION 133 | 134 | 135 | class Page(BaseModel): 136 | """A page result from a Pro API paginator.""" 137 | 138 | page: int 139 | page_count: int 140 | total_count: int 141 | results: list 142 | 143 | 144 | class Paginator: 145 | def __init__( 146 | self, 147 | api_client: ProApi, 148 | resource_path: str, 149 | return_model: Type[BaseModel], 150 | start_page: int = 0, 151 | end_page: Optional[int] = None, 152 | page_size: int = 100, 153 | sort_expression: Optional[SortExpression] = None, 154 | filter_expression: Optional[FilterExpression] = None, 155 | extra_params: Optional[Dict[str, str]] = None, 156 | ): 157 | """A paginator for the Jamf Pro API. A paginator automatically iterates over an API if 158 | multiple unreturned pages are detected in the response. Paginated requests are performed 159 | concurrently. 160 | 161 | :param api_client: A Jamf Pro API client. 162 | :type api_client: ProApi 163 | 164 | :param resource_path: The API resource path the paginator will make requests to. This path 165 | should begin with the API version and not the ``/api`` base path. 166 | :type resource_path: str 167 | 168 | :param return_model: A Pydantic model to parse the results of the request as. If not set 169 | the raw JSON response is returned. 170 | :type return_model: Type[BaseModel] 171 | 172 | :param start_page: (optional) The page to begin returning results from. Generally this value 173 | should be left at the default (``0``). 174 | 175 | .. note:: 176 | 177 | Pages in the Pro API are zero-indexed. In a response that includes 10 pages the first 178 | page is ``0`` and the last page is ``9``. 179 | 180 | :type start_page: int 181 | 182 | :param end_page: (optional) The page number to stop pagination on. The ``end_page`` argument 183 | allows for retrieving page ranges (e.g. 2 - 4) or a single page result by using the same 184 | number for both start and end values. 185 | :type start_page: int 186 | 187 | :param page_size: (optional) The number of results to include in each requested page. The 188 | default value is ``100`` and the maximum value is ``2000``. 189 | :type page_size: int 190 | 191 | :param sort_expression: (optional) The sort fields to apply to the request. See the 192 | documentation for :ref:`Pro API Sorting` for more information. 193 | :type sort_expression: SortExpression 194 | 195 | :param filter_expression: (optional) The filter expression to apply to the request. See the 196 | documentation for :ref:`Pro API Filtering` for more information. 197 | :type filter_expression: FilterExpression 198 | 199 | :param extra_params: (optional) A dictionary of key-value pairs that will be added to the 200 | query string parameters of the requests. 201 | :type extra_params: Dict[str, str] 202 | 203 | """ 204 | self._api_client = api_client 205 | self.resource_path = resource_path 206 | self.return_model = return_model 207 | self.start_page = start_page 208 | self.end_page = end_page 209 | self.page_size = page_size 210 | self.sort_expression = sort_expression 211 | self.filter_expression = filter_expression 212 | self.extra_params = extra_params 213 | 214 | def _paginated_request(self, page: int) -> Page: 215 | query_params: dict = {"page": page, "page-size": self.page_size} 216 | if self.sort_expression: 217 | query_params["sort"] = str(self.sort_expression) 218 | if self.filter_expression: 219 | query_params["filter"] = str(self.filter_expression) 220 | if self.extra_params: 221 | query_params.update(self.extra_params) 222 | 223 | response = self._api_client.api_request( 224 | method="get", resource_path=self.resource_path, query_params=query_params 225 | ).json() 226 | 227 | return Page( 228 | page=page, 229 | page_count=len(response["results"]), 230 | total_count=response["totalCount"], 231 | results=( 232 | [self.return_model.model_validate(i) for i in response["results"]] 233 | if self.return_model 234 | else response["results"] 235 | ), 236 | ) 237 | 238 | def _request(self) -> Iterator[Page]: 239 | first_page = self._paginated_request(page=self.start_page) 240 | yield first_page 241 | 242 | total_count = ( 243 | min(first_page.total_count, (self.end_page + 1) * self.page_size) 244 | if self.end_page 245 | else first_page.total_count 246 | ) 247 | 248 | if total_count > (results_count := len(first_page.results)): 249 | for page in self._api_client.concurrent_api_requests( 250 | self._paginated_request, 251 | [ 252 | {"page": i} 253 | for i in range( 254 | self.start_page + 1, 255 | math.ceil((total_count - results_count) / self.page_size) + 1, 256 | ) 257 | ], 258 | ): 259 | yield page 260 | 261 | def __call__(self, return_generator: bool = True) -> Union[List, Iterator[Page]]: 262 | """Call the instantiated paginator to return results. 263 | 264 | :param return_generator: If ``True`` a generator is returned to iterate over pages. If 265 | ``False`` the results for all pages will be returned in a single list response. 266 | :type return_generator: bool 267 | 268 | :return: An iterator that yields :class:`~Page` objects, or a list of responses if 269 | ``return_generator`` is ``False``. 270 | :rtype: Union[List, Iterator[Page]] 271 | """ 272 | generator = self._request() 273 | if return_generator: 274 | return generator 275 | else: 276 | results = [] 277 | for i in sorted([p for p in generator], key=lambda x: x.page): 278 | results.extend(i.results) 279 | 280 | return results 281 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/clients/webhooks.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import inspect 3 | import random 4 | import string 5 | import time 6 | import uuid 7 | from functools import lru_cache 8 | from typing import Iterator, Type, Union, cast 9 | 10 | import requests 11 | import requests.adapters 12 | 13 | try: 14 | from faker import Faker 15 | from polyfactory import Use 16 | from polyfactory.factories.pydantic_factory import ModelFactory 17 | except ImportError: 18 | raise ImportError("The 'webhooks' extra dependency is required.") 19 | 20 | from ..models.webhooks import webhooks 21 | 22 | WebhookGenerator = ModelFactory 23 | 24 | 25 | class WebhooksClient: 26 | def __init__( 27 | self, 28 | url: str, 29 | max_concurrency: int = 10, 30 | ): 31 | """A client for simulating webhooks to an HTTP server. 32 | 33 | :param url: The full URL the client will send webhooks to. Include the scheme, port, and path. 34 | :type url: str 35 | 36 | :param max_concurrency: The maximum number of connections the client will make when sending 37 | webhooks to the given URL (defaults to `10`). 38 | :type max_concurrency: int 39 | """ 40 | self.url = url 41 | self.max_concurrency = max_concurrency 42 | self.session = requests.Session() 43 | 44 | adapter = requests.adapters.HTTPAdapter( 45 | pool_connections=max_concurrency, pool_maxsize=max_concurrency 46 | ) 47 | self.session.mount(prefix="http://", adapter=adapter) 48 | self.session.mount(prefix="https://", adapter=adapter) 49 | 50 | @staticmethod 51 | def _batch( 52 | generator: Union[WebhookGenerator, Type[WebhookGenerator]], count: int 53 | ) -> Iterator[webhooks.WebhookModel]: 54 | for _ in range(count): 55 | yield generator.build() 56 | 57 | def send_webhook(self, webhook: webhooks.WebhookModel) -> requests.Response: 58 | """Send a single webhook in an HTTP POST request to the configured URL. 59 | 60 | :param webhook: The webhook object that will be serialized to JSON. 61 | :type webhook: ~webhooks.WebhookModel 62 | 63 | :return: `Requests Response `_ object 64 | :rtype: requests.Response 65 | """ 66 | response = self.session.post( 67 | self.url, headers={"Content-Type": "application/json"}, data=webhook.model_dump_json() 68 | ) 69 | return response 70 | 71 | def fire( 72 | self, 73 | webhook: Type[webhooks.WebhookModel], 74 | count: int = 1, 75 | ) -> Iterator[requests.Response]: 76 | """Send one or more randomized webhooks to the configured URL using a webhook model. This 77 | method will automatically make the requests concurrently up to the configured max concurrency. 78 | 79 | :param webhook: A webhook model. This must be the ``type`` and not an instantiated object. 80 | :type webhook: Type[webhooks.WebhookModel] 81 | 82 | :param count: The number of webhook events to send (defaults to `1`). 83 | :type count: int 84 | 85 | :return: An iterator that will yield 86 | `Requests Response `_ object 87 | objects. 88 | :rtype: Iterator[requests.Response] 89 | """ 90 | generator = get_webhook_generator(webhook) 91 | with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_concurrency) as executor: 92 | executor_results = executor.map( 93 | self.send_webhook, self._batch(generator=generator, count=count) 94 | ) 95 | 96 | for result in executor_results: 97 | yield result 98 | 99 | 100 | def epoch() -> int: 101 | """Returns epoch as an integer.""" 102 | return int(time.time()) 103 | 104 | 105 | def serial_number() -> str: 106 | """Returns a randomized string representing an Apple serial number.""" 107 | return "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) 108 | 109 | 110 | def udid() -> str: 111 | """Returns an upper-cased UUID.""" 112 | return str(uuid.uuid4()).upper() 113 | 114 | 115 | @lru_cache 116 | def get_webhook_generator(model: Type[webhooks.WebhookModel], **kwargs) -> Type[WebhookGenerator]: 117 | """Returns a webhook generator for the given webhook model. Generators are wrappers around 118 | webhook models to create mock data. 119 | 120 | The returned generator is cached and will be returned for any future calls for the same model. 121 | 122 | :param model: A webhook model. This must be the ``type`` and not an instantiated object. 123 | :type model: Type[webhooks.WebhookModel] 124 | """ 125 | if not kwargs: 126 | kwargs = {} 127 | 128 | return cast( 129 | "Type[WebhookGenerator]", 130 | type(model.__name__ + "Generator", (WebhookGenerator,), {"__model__": model, **kwargs}), 131 | ) 132 | 133 | 134 | def _load_webhook_generators(): 135 | """The following code runs when the file is imported. All webhook models discovered (that is, 136 | any class based from ``WebhookModel``) will be processed to set up mock data generation for key 137 | fields. The returned generator is then loaded into the ``globals()`` so they can be referenced 138 | by other models that depend on them (ensuring the fields configured for mock data are intact). 139 | """ 140 | for name, cls in webhooks.__dict__.items(): 141 | if not inspect.isclass(cls) or not issubclass(cls, webhooks.WebhookModel): 142 | continue 143 | 144 | attrs: dict = {"__set_as_default_factory_for_type__": True, "__faker__": Faker()} 145 | 146 | if issubclass(cls, webhooks.WebhookData): 147 | attrs["eventTimestamp"] = Use(epoch) 148 | 149 | elif issubclass(cls, webhooks.WebhookModel): 150 | if "macAddress" in cls.model_fields: 151 | attrs["macAddress"] = attrs["__faker__"].mac_address 152 | if "alternateMacAddress" in cls.model_fields: 153 | attrs["alternateMacAddress"] = attrs["__faker__"].mac_address 154 | if "wifiMacAddress" in cls.model_fields: 155 | attrs["wifiMacAddress"] = attrs["__faker__"].mac_address 156 | if "bluetoothMacAddress" in cls.model_fields: 157 | attrs["bluetoothMacAddress"] = attrs["__faker__"].mac_address 158 | 159 | if "udid" in cls.model_fields: 160 | attrs["udid"] = Use(udid) 161 | if "serialNumber" in cls.model_fields: 162 | attrs["serialNumber"] = Use(serial_number) 163 | 164 | # TODO: Fields that are specific to iOS/iPadOS devices 165 | # if "icciID" in cls.__fields__: 166 | # kwargs["icciID"] = Use(icci_id) 167 | # if "imei" in cls.__fields__: 168 | # kwargs["imei"] = Use(imei) 169 | 170 | if "realName" in cls.model_fields: 171 | attrs["realName"] = attrs["__faker__"].name 172 | if "username" in cls.model_fields: 173 | attrs["username"] = attrs["__faker__"].user_name 174 | if "emailAddress" in cls.model_fields: 175 | attrs["emailAddress"] = attrs["__faker__"].ascii_safe_email 176 | if "phone" in cls.model_fields: 177 | attrs["phone"] = attrs["__faker__"].phone_number 178 | 179 | w = get_webhook_generator(cls, **attrs) 180 | globals()[w.__name__] = w 181 | 182 | 183 | _load_webhook_generators() 184 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/exceptions.py: -------------------------------------------------------------------------------- 1 | class JamfProSdkException(Exception): 2 | """Base Jamf Pro SDK Exception""" 3 | 4 | pass 5 | 6 | 7 | class CredentialsError(JamfProSdkException): 8 | """Credentials Error""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | def logger_quick_setup(level=logging.INFO): 6 | """A helper function to quickly setup console logging for the SDK. Setting DEBUG 7 | logging will also apply these settings to ``urllib3``. 8 | 9 | :param level: Logging Level 10 | """ 11 | logger = logging.getLogger("jamf_pro_sdk") 12 | logger.setLevel(level) 13 | 14 | handler = logging.StreamHandler(sys.stdout) 15 | handler.setLevel(level) 16 | formatter = logging.Formatter("%(asctime)s %(name)s %(levelname)s %(threadName)s %(message)s") 17 | handler.setFormatter(formatter) 18 | 19 | logger.addHandler(handler) 20 | 21 | if level == logging.DEBUG: 22 | urllib3_logger = logging.getLogger("urllib3") 23 | urllib3_logger.setLevel(logging.DEBUG) 24 | urllib3_logger.addHandler(handler) 25 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/__init__.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel as PydanticBaseModel 2 | 3 | # Removes the docstring from the __init__ of the BaseModel for Sphinx documentation. 4 | 5 | 6 | class BaseModel(PydanticBaseModel): 7 | def __init__(self, **kwargs): 8 | """""" 9 | super().__init__(**kwargs) 10 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Dict, Iterable, Optional, Set 3 | 4 | import dicttoxml 5 | from pydantic import ConfigDict 6 | 7 | from .. import BaseModel 8 | 9 | 10 | def convert_datetime_to_jamf_iso(dt: datetime) -> str: 11 | """Classic API deviates from the published Jamf API Style Guide: 12 | https://developer.jamf.com/developer-guide/docs/api-style-guide#date--time-format 13 | """ 14 | if not dt.tzinfo: 15 | raise ValueError("Datetime object must have timezone information") 16 | 17 | return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + dt.strftime("%z") 18 | 19 | 20 | def remove_fields(data: Any, values_to_remove: Iterable = None): 21 | """For use with the .xml() method of the `ClassicApiModel`. 22 | 23 | When constructing resources using models and some default sub-models are instantiated 24 | for ease of developer use. Upon export to XML, any empty objects and arrays are removed 25 | to prevent overwriting existing data in the API. 26 | """ 27 | if not values_to_remove: 28 | values_to_remove = [{}, []] 29 | 30 | if isinstance(data, dict): 31 | new_data = {} 32 | for k, v in data.items(): 33 | if v in values_to_remove: 34 | continue 35 | elif isinstance(v, dict): 36 | v = remove_fields(v, values_to_remove) 37 | elif isinstance(v, list): 38 | new_v = [] 39 | for i in v: 40 | print("Array item: ", i) 41 | if new_i := remove_fields(i, values_to_remove): 42 | new_v.append(new_i) 43 | v = new_v 44 | 45 | new_data[k] = v 46 | return new_data 47 | 48 | elif data not in values_to_remove: 49 | return data 50 | 51 | 52 | class ClassicApiModel(BaseModel): 53 | """The base model used for Classic API models.""" 54 | 55 | model_config = ConfigDict(extra="allow", json_encoders={datetime: convert_datetime_to_jamf_iso}) 56 | 57 | _xml_root_name: str 58 | _xml_array_item_names: Dict[str, str] 59 | _xml_write_fields: Optional[Set[str]] = None 60 | 61 | def xml(self, exclude_none: bool = True, exclude_read_only: bool = False) -> str: 62 | """Generate a Jamf Pro XML representation of the model. 63 | 64 | :param exclude_none: Any field with a value of ``None`` is not included. 65 | :type exclude_none: bool 66 | 67 | :param exclude_read_only: If write field names are set, only include those. 68 | :type exclude_read_only: bool 69 | 70 | :return: XML string 71 | :rtype: str 72 | """ 73 | data = remove_fields( 74 | self.model_dump( 75 | include=self._xml_write_fields if exclude_read_only else None, 76 | exclude_none=exclude_none, 77 | ) 78 | ) 79 | 80 | return dicttoxml.dicttoxml( 81 | data, 82 | custom_root=self._xml_root_name, 83 | attr_type=False, 84 | item_func=self._xml_array_item_names.get, 85 | return_bytes=False, 86 | ) 87 | 88 | 89 | class ClassicDeviceLocation(BaseModel): 90 | """Device user assignment information.""" 91 | 92 | model_config = ConfigDict(extra="allow") 93 | 94 | username: Optional[str] = None 95 | realname: Optional[str] = None 96 | real_name: Optional[str] = None 97 | email_address: Optional[str] = None 98 | position: Optional[str] = None 99 | phone: Optional[str] = None 100 | phone_number: Optional[str] = None 101 | department: Optional[str] = None 102 | building: Optional[str] = None 103 | room: Optional[str] = None 104 | 105 | 106 | class ClassicDevicePurchasing(BaseModel): 107 | """Device purchase information (normally populated by GSX).""" 108 | 109 | model_config = ConfigDict(extra="allow") 110 | 111 | is_purchased: Optional[bool] = None 112 | is_leased: Optional[bool] = None 113 | po_number: Optional[str] = None 114 | vendor: Optional[str] = None 115 | applecare_id: Optional[str] = None 116 | purchase_price: Optional[str] = None 117 | purchasing_account: Optional[str] = None 118 | po_date: Optional[str] = None 119 | po_date_epoch: Optional[int] = None 120 | po_date_utc: Optional[str] = None 121 | warranty_expires: Optional[str] = None 122 | warranty_expires_epoch: Optional[int] = None 123 | warranty_expires_utc: Optional[str] = None 124 | lease_expires: Optional[str] = None 125 | lease_expires_epoch: Optional[int] = None 126 | lease_expires_utc: Optional[str] = None 127 | life_expectancy: Optional[int] = None 128 | purchasing_contact: Optional[str] = None 129 | os_applecare_id: Optional[str] = None 130 | os_maintenance_expires: Optional[str] = None 131 | attachments: Optional[list] = None # Deprecated? 132 | 133 | 134 | class ClassicSite(BaseModel): 135 | """Site assignment information.""" 136 | 137 | id: Optional[int] = None 138 | name: Optional[str] = None 139 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/advanced_computer_searches.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import ConfigDict 4 | 5 | from .. import BaseModel 6 | from . import ClassicApiModel, ClassicSite 7 | from .criteria import ClassicCriterion 8 | 9 | _XML_ARRAY_ITEM_NAMES = { 10 | "advanced_computer_searches": "advanced_computer_search", 11 | "computers": "computer", 12 | "display_fields": "display_field", 13 | } 14 | 15 | 16 | class ClassicAdvancedComputerSearchDisplayField(BaseModel): 17 | """ClassicAdvancedComputerSearch nested model: advanced_computer_search.display_fields. 18 | 19 | Display fields are additional data that are returned with the results of an advanced search. 20 | Note that the API field names are **not** the same as the display field names. Refer to the 21 | Jamf Pro UI for the supported names. 22 | """ 23 | 24 | model_config = ConfigDict(extra="allow") 25 | 26 | name: Optional[str] = None 27 | 28 | 29 | class ClassicAdvancedComputerSearchResult(BaseModel): 30 | """ClassicAdvancedComputerSearch nested model: advanced_computer_search.computers. 31 | 32 | In addition to the ``id``, ``name``, and ``udid`` fields, any defined display fields will also 33 | appear with their values from the inventory record. 34 | """ 35 | 36 | model_config = ConfigDict(extra="allow") 37 | 38 | id: Optional[int] = None 39 | name: Optional[str] = None 40 | udid: Optional[str] = None 41 | 42 | 43 | class ClassicAdvancedComputerSearchesItem(ClassicApiModel): 44 | """Represents an advanced computer search record returned by the 45 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_all_advanced_computer_searches` 46 | operation. 47 | """ 48 | 49 | model_config = ConfigDict(extra="allow") 50 | 51 | id: Optional[int] = None 52 | name: Optional[str] = None 53 | 54 | 55 | class ClassicAdvancedComputerSearch(ClassicApiModel): 56 | """Represents an advanced computer search record returned by the 57 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_all_advanced_computer_searches` 58 | operation. 59 | 60 | When exporting to XML for a ``POST``/``PUT`` operation, the SDK by default will only 61 | include ``name``, ``site``, ``criteria``, and ``display_fields``. To bypass this behavior 62 | export the model using 63 | :meth:`~jamf_pro_sdk.models.classic.ClassicApiModel.xml` before pasting to the API 64 | operation. 65 | """ 66 | 67 | model_config = ConfigDict(extra="allow") 68 | 69 | _xml_root_name = "advanced_computer_search" 70 | _xml_array_item_names = _XML_ARRAY_ITEM_NAMES 71 | _xml_write_fields = {"name", "site", "criteria", "display_fields"} 72 | 73 | id: Optional[int] = None 74 | name: Optional[str] = None 75 | site: Optional[ClassicSite] = None 76 | criteria: Optional[List[ClassicCriterion]] = None 77 | display_fields: Optional[List[ClassicAdvancedComputerSearchDisplayField]] = None 78 | computers: Optional[List[ClassicAdvancedComputerSearchResult]] = None 79 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/categories.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import ConfigDict 4 | 5 | from .. import BaseModel 6 | from . import ClassicApiModel 7 | 8 | _XML_ARRAY_ITEM_NAMES = {} 9 | 10 | 11 | class ClassicCategoriesItem(BaseModel): 12 | """Represents a category record returned by the 13 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_all_categories` operation. 14 | """ 15 | 16 | model_config = ConfigDict(extra="allow") 17 | 18 | id: Optional[int] = None 19 | name: Optional[str] = None 20 | 21 | 22 | class ClassicCategory(ClassicApiModel): 23 | """Represents a category record returned by the 24 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.get_category_by_id` operation. 25 | 26 | When exporting to XML for a ``POST``/``PUT`` operation, the SDK by default will only 27 | include ``name``, and ``priority``. To bypass this 28 | behavior export the model using :meth:`~jamf_pro_sdk.models.classic.ClassicApiModel.xml` before 29 | passing to the API operation. 30 | """ 31 | 32 | model_config = ConfigDict(extra="allow") 33 | 34 | _xml_root_name = "category" 35 | _xml_array_item_names = _XML_ARRAY_ITEM_NAMES 36 | _xml_write_fields = {"name", "priority"} 37 | 38 | id: Optional[int] = None 39 | name: Optional[str] = None 40 | priority: Optional[int] = None 41 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/computer_groups.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import ConfigDict 4 | 5 | from .. import BaseModel 6 | from . import ClassicApiModel, ClassicSite 7 | from .criteria import ClassicCriterion 8 | 9 | _XML_ARRAY_ITEM_NAMES = { 10 | "criteria": "criterion", 11 | "computers": "computer", 12 | "computer_additions": "computer", 13 | "computer_deletions": "computer", 14 | } 15 | 16 | 17 | # class ClassicComputerGroupsItem(BaseModel, extra=Extra.allow): 18 | # """Represents a computer group record returned by the 19 | # :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_computer_groups` operation. 20 | # """ 21 | # 22 | # id: int 23 | # name: str 24 | # is_smart: bool 25 | 26 | 27 | class ClassicComputerGroupMember(BaseModel): 28 | """ComputerGroup nested model: computer_group.computers, 29 | computer_group.computer_additions, computer_group.computer_deletions 30 | """ 31 | 32 | model_config = ConfigDict(extra="allow") 33 | 34 | id: Optional[int] = None 35 | name: Optional[str] = None 36 | mac_address: Optional[str] = None 37 | alt_mac_address: Optional[str] = None 38 | serial_number: Optional[str] = None 39 | 40 | 41 | class ClassicComputerGroup(ClassicApiModel): 42 | """Represents a computer group record returned by the 43 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_computer_groups` and 44 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.get_computer_group_by_id` 45 | operations. 46 | 47 | When returned by ``list_computer_groups`` only ``id``, ``name`` and ``is_smart`` will 48 | be populated. 49 | 50 | When exporting to XML for a ``POST``/``PUT`` operation, the SDK by default will only 51 | include ``name``, ``is_smart``, ``site``, and ``criteria``. To bypass this behavior 52 | export the model using 53 | :meth:`~jamf_pro_sdk.models.classic.ClassicApiModel.xml` before pasting to the API 54 | operation. 55 | """ 56 | 57 | model_config = ConfigDict(extra="allow") 58 | 59 | _xml_root_name = "computer_group" 60 | _xml_array_item_names = _XML_ARRAY_ITEM_NAMES 61 | _xml_write_fields = {"name", "is_smart", "site", "criteria"} 62 | 63 | id: Optional[int] = None 64 | name: Optional[str] = None 65 | is_smart: Optional[bool] = None 66 | site: Optional[ClassicSite] = None 67 | criteria: Optional[List[ClassicCriterion]] = None 68 | computers: Optional[List[ClassicComputerGroupMember]] = None 69 | 70 | 71 | class ClassicComputerGroupMembershipUpdate(ClassicApiModel): 72 | """Represents a computer group membership update. This model is generated as a part of the 73 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.update_static_computer_group_membership_by_id` 74 | operation. 75 | """ 76 | 77 | _xml_root_name = "computer_group" 78 | _xml_array_item_names = _XML_ARRAY_ITEM_NAMES 79 | 80 | computer_additions: Optional[List[ClassicComputerGroupMember]] = None 81 | computer_deletions: Optional[List[ClassicComputerGroupMember]] = None 82 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/criteria.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | 6 | class ClassicCriterionAndOr(str, Enum): 7 | and_: str = "and" 8 | or_: str = "or" 9 | 10 | 11 | class ClassicCriterionSearchType(str, Enum): 12 | """Supported search types for Classic API criteria. 13 | 14 | .. attention:: 15 | 16 | The supported search types are dependent on the field that is entered in the criterion's 17 | ``name`` field. 18 | 19 | """ 20 | 21 | is_: str = "is" 22 | is_not: str = "is not" 23 | like: str = "like" 24 | not_like: str = "not like" 25 | has: str = "has" 26 | does_not_have: str = "does not have" 27 | matches_regex: str = "matches regex" 28 | does_not_match_regex: str = "does not match regex" 29 | before_yyyy_mm_dd: str = "before (yyyy-mm-dd)" 30 | after_yyyy_mm_dd: str = "after (yyyy-mm-dd)" 31 | more_than_x_days_ago: str = "more than x days ago" 32 | less_than_x_days_ago: str = "less than x days ago" 33 | current: str = "current" 34 | not_current: str = "not current" 35 | member_of: str = "member of" 36 | not_member_of: str = "not member of" 37 | more_than: str = "more than" 38 | less_than: str = "less than" 39 | greater_than: str = "greater than" 40 | greater_than_or_equal: str = "greater than or equal" 41 | less_than_or_equal: str = "less than or equal" 42 | 43 | 44 | class ClassicCriterion(BaseModel): 45 | """Classic API criterion. Used by Smart Groups and Advanced Searches.""" 46 | 47 | model_config = ConfigDict(extra="allow", use_enum_values=True) 48 | 49 | name: str 50 | priority: int 51 | and_or: ClassicCriterionAndOr 52 | search_type: ClassicCriterionSearchType 53 | value: str 54 | opening_paren: bool 55 | closing_paren: bool 56 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/network_segments.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import ConfigDict 4 | 5 | from .. import BaseModel 6 | from . import ClassicApiModel 7 | 8 | _XML_ARRAY_ITEM_NAMES = {} 9 | 10 | 11 | class ClassicNetworkSegmentItem(BaseModel): 12 | """Represents a network_segment record returned by the 13 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_network_segments` operation. 14 | """ 15 | 16 | model_config = ConfigDict(extra="allow") 17 | 18 | id: Optional[int] = None 19 | name: Optional[str] = None 20 | starting_address: Optional[str] = None 21 | ending_address: Optional[str] = None 22 | 23 | 24 | class ClassicNetworkSegment(ClassicApiModel): 25 | """Represents a network_segment record returned by the 26 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.get_network_segment_by_id` operation. 27 | """ 28 | 29 | model_config = ConfigDict(extra="allow") 30 | 31 | _xml_root_name = "network_segment" 32 | _xml_array_item_names = _XML_ARRAY_ITEM_NAMES 33 | _xml_write_fields = { 34 | "name", 35 | "starting_address", 36 | "ending_address", 37 | "distribution_server", 38 | "distribution_point", 39 | "url", 40 | "swu_server", 41 | "building", 42 | "department", 43 | "override_buildings", 44 | "override_departments", 45 | } 46 | 47 | id: Optional[int] = None 48 | name: Optional[str] = None 49 | starting_address: Optional[str] = None 50 | ending_address: Optional[str] = None 51 | distribution_server: Optional[str] = None 52 | distribution_point: Optional[str] = None 53 | url: Optional[str] = None 54 | swu_server: Optional[str] = None 55 | building: Optional[str] = None 56 | department: Optional[str] = None 57 | override_buildings: Optional[bool] = None 58 | override_departments: Optional[bool] = None 59 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/classic/packages.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from pydantic import ConfigDict 4 | 5 | from .. import BaseModel 6 | from . import ClassicApiModel 7 | 8 | _XML_ARRAY_ITEM_NAMES: dict = {} 9 | 10 | 11 | class ClassicPackageItem(BaseModel): 12 | """Represents a package record returned by the 13 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.list_packages` operation. 14 | """ 15 | 16 | model_config = ConfigDict(extra="allow") 17 | 18 | id: Optional[int] = None 19 | name: Optional[str] = None 20 | 21 | 22 | class ClassicPackage(ClassicApiModel): 23 | """Represents a package returned by the 24 | :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.get_package_by_id` operation. 25 | 26 | When exporting to XML for a ``POST``/``PUT`` operation, the SDK by default will only 27 | include ``name``, ``category``, ``filename``, ``info``, ``notes``, ``priority``, 28 | ``reboot_required``, ``os_requirements``, and ``install_if_reported_available``. To bypass this 29 | behavior export the model using :meth:`~jamf_pro_sdk.models.classic.ClassicApiModel.xml` before 30 | pasting to the API operation. 31 | """ 32 | 33 | model_config = ConfigDict(extra="allow") 34 | 35 | _xml_root_name = "package" 36 | _xml_array_item_names = _XML_ARRAY_ITEM_NAMES 37 | _xml_write_fields = { 38 | "name", 39 | "category", 40 | "filename", 41 | "info", 42 | "notes", 43 | "priority", 44 | "reboot_required", 45 | "os_requirements", 46 | "install_if_reported_available", 47 | } 48 | 49 | id: Optional[int] = None 50 | name: Optional[str] = None 51 | category: Optional[str] = None 52 | filename: Optional[str] = None 53 | info: Optional[str] = None 54 | notes: Optional[str] = None 55 | priority: Optional[int] = None 56 | reboot_required: Optional[bool] = None 57 | fill_user_template: Optional[bool] = None 58 | fill_existing_users: Optional[bool] = None 59 | allow_uninstalled: Optional[bool] = None 60 | os_requirements: Optional[str] = None 61 | required_processor: Optional[str] = None 62 | hash_type: Optional[str] = None 63 | hash_value: Optional[str] = None 64 | switch_with_package: Optional[str] = None 65 | install_if_reported_available: Optional[bool] = None 66 | reinstall_option: Optional[str] = None 67 | triggering_files: Optional[Union[dict, str]] = None 68 | send_notification: Optional[bool] = None 69 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/client.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from datetime import datetime, timedelta, timezone 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import List, Optional, Union 6 | 7 | from ..__about__ import __version__ 8 | from . import BaseModel 9 | 10 | DEFAULT_USER_AGENT = f"JamfProSDK/{__version__} {platform.system()}/{platform.release()} Python/{platform.python_version()}" 11 | EPOCH_DATETIME = datetime(1970, 1, 1, tzinfo=timezone.utc) 12 | 13 | 14 | class Schemes(str, Enum): 15 | http = "http" 16 | https = "https" 17 | 18 | 19 | class SessionConfig(BaseModel): 20 | """Jamf Pro client session configuration. 21 | 22 | :param timeout: HTTP request timeout (defaults to no timeout set). 23 | :type timeout: int 24 | 25 | :param max_retries: HTTP request retries (defaults to `0`). 26 | :type max_retries: int 27 | 28 | :param max_concurrency: The maximum number of HTTP connections the client will create when 29 | making concurrent requests (defaults to `5`). 30 | :type max_concurrency: int 31 | 32 | :param return_exceptions: Global setting that controls returning exceptions when 33 | :meth:`~jamf_pro_sdk.clients.JamfProClient.concurrent_operations` is invoked. Setting this 34 | to ``True`` will return the exception object if an error is encountered by the ``handler``. 35 | If ``False`` no response will be given for that operation. 36 | 37 | :param user_agent: Override the default ``User-Agent`` string included with SDK requests. 38 | :type user_agent: str 39 | 40 | :param verify: TLS certificate verification (defaults to `True`). 41 | :type verify: bool 42 | 43 | :param cookie: A path to a text cookie file to attach to the client session. 44 | :type cookie: str | Path 45 | 46 | :param ca_cert_bundle: A path to a CA cert bundle to use in addition to the system trust store. 47 | :type ca_cert_bundle: str | Path 48 | 49 | :param scheme: Override the URL scheme to `http` (defaults to `https`). **It is strongly 50 | advised that you use HTTPS for certificate verification.** 51 | :type scheme: str 52 | """ 53 | 54 | timeout: Optional[int] = None 55 | max_retries: int = 0 56 | max_concurrency: int = 5 57 | return_exceptions: bool = True 58 | user_agent: str = DEFAULT_USER_AGENT 59 | verify: bool = True 60 | cookie: Optional[Union[str, Path]] = None 61 | ca_cert_bundle: Optional[Union[str, Path]] = None 62 | scheme: Schemes = Schemes.https 63 | 64 | 65 | class AccessToken(BaseModel): 66 | """Jamf Pro access token. Used by a :class:`~jamf_pro_sdk.clients.auth.CredentialsProvider` 67 | object to manage an access token. 68 | 69 | :param type: The type name of the access token. This should only be ``user`` or ``oauth``. 70 | :type type: str 71 | 72 | :param token: The raw access token string. 73 | :type token: str 74 | 75 | :param expires: The expiration time of the token represented as a ``datetime`` object. 76 | :type expires: datetime 77 | 78 | :param scope: If the access token is an ``oauth`` type the scope claim will be passed as a list 79 | of string values. 80 | :type scope: List[str] 81 | """ 82 | 83 | type: str = "" 84 | token: str = "" 85 | expires: datetime = EPOCH_DATETIME 86 | scope: Optional[List[str]] = None 87 | 88 | def __str__(self): 89 | return self.token 90 | 91 | @property 92 | def is_expired(self) -> bool: 93 | """Has the current time passed the token's expiration time? 94 | Will return `False` if the current time is within 5 seconds of the 95 | token's expiration time. 96 | """ 97 | return self.expires - timedelta(seconds=5) < datetime.now(timezone.utc) 98 | 99 | @property 100 | def seconds_remaining(self) -> int: 101 | """The number of seconds until the token expires.""" 102 | return max(0, int((self.expires - datetime.now(timezone.utc)).total_seconds())) 103 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/pro/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class V1Site(BaseModel): 7 | id: Optional[str] = None 8 | name: Optional[str] = None 9 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/pro/jcds2.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel, ConfigDict 5 | 6 | 7 | class NewFile(BaseModel): 8 | model_config = ConfigDict(extra="allow") 9 | 10 | accessKeyID: str 11 | secretAccessKey: str 12 | sessionToken: str 13 | region: str 14 | expiration: datetime 15 | bucketName: str 16 | path: str 17 | uuid: UUID 18 | 19 | 20 | class File(BaseModel): 21 | model_config = ConfigDict(extra="allow") 22 | 23 | region: str 24 | fileName: str 25 | length: int 26 | md5: str 27 | sha3: str 28 | 29 | 30 | class DownloadUrl(BaseModel): 31 | model_config = ConfigDict(extra="allow") 32 | 33 | uri: str 34 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/pro/mdm.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Annotated, List, Literal, Optional, Union 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, ConfigDict, Field, StringConstraints 7 | 8 | from .api_options import get_mdm_commands_v2_allowed_command_types 9 | 10 | # A note on MDM Command Types: 11 | # The ``get_mdm_commands_v2_allowed_command_types`` list in the ``api_options`` file is referenced 12 | # in the Jamf Pro OpenAPI schema (10.50) for allowed command types, but the API will reject all but 13 | # a few allowed types. 14 | 15 | 16 | # Enable Lost Mode Command 17 | 18 | 19 | class EnableLostModeCommand(BaseModel): 20 | """MDM command to enable Lost Mode. 21 | 22 | .. code-block:: python 23 | 24 | command = EnableLostModeCommand() 25 | command.lostModeMessage = "Please return me to my owner." 26 | command.lostModePhone = "123-456-7890" 27 | command.lostModeFootnote = "No reward." 28 | 29 | Alternatively, unpack a dictionary: 30 | 31 | .. code-block:: python 32 | 33 | command = EnableLostModeCommand( 34 | **{ 35 | "lostModeMessage": "Please return me to my owner.", 36 | "lostModePhone": "123-456-7890", 37 | "lostModeFootnote": "No reward." 38 | } 39 | ) 40 | 41 | """ 42 | 43 | commandType: Literal["ENABLE_LOST_MODE"] = "ENABLE_LOST_MODE" 44 | lostModeMessage: str 45 | lostModePhone: str 46 | lostModeFootnote: str 47 | 48 | 49 | # Erase Device Command Models 50 | 51 | 52 | class EraseDeviceCommandObliterationBehavior(str, Enum): 53 | """Define the fallback behavior for erasing a device.""" 54 | 55 | Default = "Default" 56 | DoNotObliterate = "DoNotObliterate" 57 | ObliterateWithWarning = "ObliterateWithWarning" 58 | Always = "Always" 59 | 60 | 61 | class EraseDeviceCommandReturnToService(BaseModel): 62 | """Configuration settings for Return to Service. 63 | 64 | The ``mdmProfileData`` and `w`ifiProfileData`` values must e base64 encoded strings. 65 | """ 66 | 67 | enabled: Literal[True] 68 | # TODO: Add automatic conversion to base64 encoded profile if the provided data is a dictionary. 69 | mdmProfileData: str 70 | wifiProfileData: str 71 | 72 | 73 | EraseDeviceCommandPin = Annotated[str, StringConstraints(min_length=6, max_length=6)] 74 | 75 | 76 | class EraseDeviceCommand(BaseModel): 77 | """MDM command to remotely wipe a device. Optionally, set the ``returnToService`` property to 78 | automatically connect to a wireless network at Setup Assistant. 79 | 80 | .. code-block:: python 81 | 82 | command = EraseDeviceCommand() 83 | command.pin = "123456" 84 | command.obliterationBehavior = EraseDeviceCommandObliterationBehavior.ObliterateWithWarning 85 | 86 | Alternatively, unpack a dictionary: 87 | 88 | .. code-block:: python 89 | 90 | command = EraseDeviceCommand( 91 | **{ 92 | "pin": "Please return me to my owner.", 93 | "obliterationBehavior": "ObliterateWithWarning" 94 | } 95 | ) 96 | """ 97 | 98 | commandType: Literal["ERASE_DEVICE"] = "ERASE_DEVICE" 99 | preserveDataPlan: Optional[bool] = None 100 | disallowProximitySetup: Optional[bool] = None 101 | pin: Optional[EraseDeviceCommandPin] = None 102 | obliterationBehavior: Optional[EraseDeviceCommandObliterationBehavior] = None 103 | returnToService: Optional[EraseDeviceCommandReturnToService] = None 104 | 105 | 106 | # Log Out User 107 | 108 | 109 | class LogOutUserCommand(BaseModel): 110 | """MDM command to log a user out of the device. 111 | 112 | .. code-block:: python 113 | 114 | command = LogOutUserCommand() 115 | 116 | """ 117 | 118 | commandType: Literal["LOG_OUT_USER"] = "LOG_OUT_USER" 119 | 120 | 121 | # Restart Device 122 | 123 | 124 | class RestartDeviceCommand(BaseModel): 125 | """MDM command to restart a device. 126 | 127 | ``kextPaths`` is only used if ``rebuildKernelCache`` is ``true``. 128 | 129 | .. code-block:: python 130 | 131 | command = RestartDeviceCommand() 132 | command.notifyUser = True 133 | 134 | Alternatively, unpack a dictionary: 135 | 136 | .. code-block:: python 137 | 138 | command = RestartDeviceCommand( 139 | **{ 140 | "notifyUser": True 141 | } 142 | ) 143 | """ 144 | 145 | commandType: Literal["RESTART_DEVICE"] = "RESTART_DEVICE" 146 | rebuildKernelCache: Optional[bool] 147 | kextPaths: Optional[List[str]] 148 | notifyUser: Optional[bool] 149 | 150 | 151 | # Set Recovery Lock 152 | 153 | 154 | class SetRecoveryLockCommand(BaseModel): 155 | """MDM command to set Recovery Lock on a device. 156 | 157 | Set ``newPassword`` to an empty string to clear the Recovery Lock password. 158 | 159 | .. code-block:: python 160 | 161 | command = SetRecoveryLockCommand() 162 | command.newPassword = "jamf1234" 163 | 164 | Alternatively, unpack a dictionary: 165 | 166 | .. code-block:: python 167 | 168 | command = SetRecoveryLockCommand( 169 | **{ 170 | "newPassword": "jamf1234" 171 | } 172 | ) 173 | 174 | """ 175 | 176 | commandType: Literal["SET_RECOVERY_LOCK"] = "SET_RECOVERY_LOCK" 177 | newPassword: str 178 | 179 | 180 | # Shut Down Device 181 | 182 | 183 | class ShutDownDeviceCommand(BaseModel): 184 | """MDM command to shut down a device. 185 | 186 | .. code-block:: python 187 | 188 | command = ShutDownDeviceCommand() 189 | 190 | """ 191 | 192 | commandType: Literal["SHUT_DOWN_DEVICE"] = "SHUT_DOWN_DEVICE" 193 | 194 | 195 | # Custom Command 196 | 197 | 198 | class CustomCommand(BaseModel): 199 | """A free form model for new commands not yet supported by the SDK.""" 200 | 201 | model_config = ConfigDict(extra="allow") 202 | 203 | commandType: str 204 | 205 | 206 | # MDM Send Command Models 207 | 208 | 209 | class SendMdmCommandClientData(BaseModel): 210 | managementId: Union[str, UUID] 211 | 212 | 213 | BuiltInCommands = Annotated[ 214 | Union[ 215 | EnableLostModeCommand, 216 | EraseDeviceCommand, 217 | LogOutUserCommand, 218 | RestartDeviceCommand, 219 | SetRecoveryLockCommand, 220 | ShutDownDeviceCommand, 221 | ], 222 | Field(..., discriminator="commandType"), 223 | ] 224 | 225 | 226 | class SendMdmCommand(BaseModel): 227 | clientData: List[SendMdmCommandClientData] 228 | commandData: Union[BuiltInCommands, CustomCommand] 229 | 230 | 231 | # MDM Command Responses 232 | 233 | 234 | class SendMdmCommandResponse(BaseModel): 235 | model_config = ConfigDict(extra="allow") 236 | 237 | id: str 238 | href: str 239 | 240 | 241 | class RenewMdmProfileResponse(BaseModel): 242 | """This response model flattens the normal API JSON response from a nested 243 | ``udidsNotProcessed.uuids`` array to just ``udidsNotProcessed``. 244 | """ 245 | 246 | model_config = ConfigDict(extra="allow") 247 | 248 | udidsNotProcessed: Optional[List[UUID]] 249 | 250 | 251 | # MDM Command Status Models 252 | 253 | 254 | class MdmCommandStatusClientTypes(str, Enum): 255 | MOBILE_DEVICE = "MOBILE_DEVICE" 256 | TV = "TV" 257 | COMPUTER = "COMPUTER" 258 | COMPUTER_USER = "COMPUTER_USER" 259 | MOBILE_DEVICE_USER = "MOBILE_DEVICE_USER" 260 | 261 | 262 | class MdmCommandStatusClient(BaseModel): 263 | model_config = ConfigDict(extra="allow") 264 | 265 | managementId: UUID 266 | clientType: MdmCommandStatusClientTypes 267 | 268 | 269 | class MdmCommandStatusStates(str, Enum): 270 | PENDING = "PENDING" 271 | ACKNOWLEDGED = "ACKNOWLEDGED" 272 | NOT_NOW = "NOT_NOW" 273 | ERROR = "ERROR" 274 | 275 | 276 | # Enum created from values in API Options 277 | MdmCommandStatusTypes = Enum( 278 | "MdmCommandStatusTypes", {i: i for i in get_mdm_commands_v2_allowed_command_types} 279 | ) 280 | 281 | 282 | class MdmCommandStatus(BaseModel): 283 | model_config = ConfigDict(extra="allow") 284 | 285 | uuid: UUID 286 | client: MdmCommandStatusClient 287 | commandState: MdmCommandStatusStates 288 | commandType: MdmCommandStatusTypes 289 | dateSent: datetime 290 | dateCompleted: datetime 291 | profileId: Optional[int] = None 292 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/pro/mobile_devices.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | 5 | from pydantic import ConfigDict 6 | 7 | from .. import BaseModel 8 | 9 | 10 | class MobileDeviceType(str, Enum): 11 | """Not in use: the value of this attribute can be an undocumented state.""" 12 | 13 | iOS = "iOS" 14 | tvOS = "tvOS" 15 | 16 | 17 | class MobileDeviceExtensionAttributeType(str, Enum): 18 | STRING = "STRING" 19 | INTEGER = "INTEGER" 20 | DATE = "DATE" 21 | 22 | 23 | class MobileDeviceExtensionAttribute(BaseModel): 24 | model_config = ConfigDict(extra="allow") 25 | 26 | id: Optional[str] = None 27 | name: Optional[str] = None 28 | type: Optional[MobileDeviceExtensionAttributeType] = None 29 | value: Optional[List[str]] = None 30 | extensionAttributeCollectionAllowed: Optional[bool] = None 31 | inventoryDisplay: Optional[str] = None 32 | 33 | 34 | class MobileDeviceHardware(BaseModel): 35 | model_config = ConfigDict(extra="allow") 36 | 37 | capacityMb: Optional[int] = None 38 | availableSpaceMb: Optional[int] = None 39 | usedSpacePercentage: Optional[int] = None 40 | batteryLevel: Optional[int] = None 41 | serialNumber: Optional[str] = None 42 | wifiMacAddress: Optional[str] = None 43 | bluetoothMacAddress: Optional[str] = None 44 | modemFirmwareVersion: Optional[str] = None 45 | model: Optional[str] = None 46 | modelIdentifier: Optional[str] = None 47 | modelNumber: Optional[str] = None 48 | bluetoothLowEnergyCapable: Optional[bool] = None 49 | deviceId: Optional[str] = None 50 | extensionAttributes: Optional[List[MobileDeviceExtensionAttribute]] = None 51 | 52 | 53 | class MobileDeviceUserAndLocation(BaseModel): 54 | model_config = ConfigDict(extra="allow") 55 | 56 | username: Optional[str] = None 57 | realName: Optional[str] = None 58 | emailAddress: Optional[str] = None 59 | position: Optional[str] = None 60 | phoneNumber: Optional[str] = None 61 | departmentId: Optional[str] = None 62 | buildingId: Optional[str] = None 63 | room: Optional[str] = None 64 | building: Optional[str] = None 65 | department: Optional[str] = None 66 | extensionAttributes: Optional[List[MobileDeviceExtensionAttribute]] = None 67 | 68 | 69 | class MobileDevicePurchasing(BaseModel): 70 | model_config = ConfigDict(extra="allow") 71 | 72 | purchased: Optional[bool] = None 73 | leased: Optional[bool] = None 74 | poNumber: Optional[str] = None 75 | vendor: Optional[str] = None 76 | appleCareId: Optional[str] = None 77 | purchasePrice: Optional[str] = None 78 | purchasingAccount: Optional[str] = None 79 | poDate: Optional[datetime] = None 80 | warrantyExpiresDate: Optional[datetime] = None 81 | leaseExpiresDate: Optional[datetime] = None 82 | lifeExpectancy: Optional[int] = None 83 | purchasingContact: Optional[str] = None 84 | extensionAttributes: Optional[List[MobileDeviceExtensionAttribute]] = None 85 | 86 | 87 | class MobileDeviceApplication(BaseModel): 88 | model_config = ConfigDict(extra="allow") 89 | 90 | identifier: Optional[str] = None 91 | name: Optional[str] = None 92 | version: Optional[str] = None 93 | shortVersion: Optional[str] = None 94 | managementStatus: Optional[str] = None 95 | validationStatus: Optional[bool] = None 96 | bundleSize: Optional[str] = None 97 | dynamicSize: Optional[str] = None 98 | 99 | 100 | class MobileDeviceCertificate(BaseModel): 101 | model_config = ConfigDict(extra="allow") 102 | 103 | commonName: Optional[str] = None 104 | identity: Optional[bool] = None 105 | expirationDate: Optional[datetime] = None 106 | 107 | 108 | class MobileDeviceProfile(BaseModel): 109 | model_config = ConfigDict(extra="allow") 110 | 111 | displayName: Optional[str] = None 112 | version: Optional[str] = None 113 | uuid: Optional[str] = None 114 | identifier: Optional[str] = None 115 | removable: Optional[bool] = None 116 | lastInstalled: Optional[datetime] = None 117 | 118 | 119 | class MobileDeviceUserProfile(MobileDeviceProfile): 120 | """Extends :class:`~jamf_pro_sdk.models.pro.mobile_devices.MobileDeviceProfile`.""" 121 | 122 | model_config = ConfigDict(extra="allow") 123 | 124 | username: Optional[str] = None 125 | 126 | 127 | class MobileDeviceOwnershipType(str, Enum): 128 | Institutional = "Institutional" 129 | PersonalDeviceProfile = "PersonalDeviceProfile" 130 | UserEnrollment = "UserEnrollment" 131 | AccountDrivenUserEnrollment = "AccountDrivenUserEnrollment" 132 | AccountDrivenDeviceEnrollment = "AccountDrivenDeviceEnrollment" 133 | 134 | 135 | class MobileDeviceEnrollmentMethodPrestage(BaseModel): 136 | model_config = ConfigDict(extra="allow") 137 | 138 | mobileDevicePrestageId: Optional[str] = None 139 | profileName: Optional[str] = None 140 | 141 | 142 | class MobileDeviceGeneral(BaseModel): 143 | model_config = ConfigDict(extra="allow") 144 | 145 | udid: Optional[str] = None 146 | displayName: Optional[str] = None 147 | assetTag: Optional[str] = None 148 | siteId: Optional[str] = None 149 | lastInventoryUpdateDate: Optional[datetime] = None 150 | osVersion: Optional[str] = None 151 | osRapidSecurityResponse: Optional[str] = None 152 | osBuild: Optional[str] = None 153 | osSupplementalBuildVersion: Optional[str] = None 154 | softwareUpdateDeviceId: Optional[str] = None 155 | ipAddress: Optional[str] = None 156 | managed: Optional[bool] = None 157 | supervised: Optional[bool] = None 158 | deviceOwnershipType: Optional[MobileDeviceOwnershipType] = None 159 | enrollmentMethodPrestage: Optional[MobileDeviceEnrollmentMethodPrestage] = None 160 | enrollmentSessionTokenValid: Optional[bool] = None 161 | lastEnrolledDate: Optional[datetime] = None 162 | mdmProfileExpirationDate: Optional[datetime] = None 163 | timeZone: Optional[str] = None 164 | declarativeDeviceManagementEnabled: Optional[bool] = None 165 | extensionAttributes: Optional[List[MobileDeviceExtensionAttribute]] = None 166 | airPlayPassword: Optional[str] = None 167 | locales: Optional[str] = None 168 | languages: Optional[str] = None 169 | 170 | 171 | class MobileDeviceSecurityLostModeLocation(BaseModel): 172 | """iOS devices only.""" 173 | 174 | model_config = ConfigDict(extra="allow") 175 | 176 | lastLocationUpdate: Optional[datetime] = None 177 | lostModeLocationHorizontalAccuracyMeters: Optional[int] = None 178 | lostModeLocationVerticalAccuracyMeters: Optional[int] = None 179 | lostModeLocationAltitudeMeters: Optional[int] = None 180 | lostModeLocationSpeedMetersPerSecond: Optional[int] = None 181 | lostModeLocationCourseDegrees: Optional[int] = None 182 | lostModeLocationTimestamp: Optional[str] = None 183 | 184 | 185 | class MobileDeviceSecurity(BaseModel): 186 | """iOS devices only.""" 187 | 188 | model_config = ConfigDict(extra="allow") 189 | 190 | dataProtected: Optional[bool] = None 191 | blockLevelEncryptionCapable: Optional[bool] = None 192 | fileLevelEncryptionCapable: Optional[bool] = None 193 | passcodePresent: Optional[bool] = None 194 | passcodeCompliant: Optional[bool] = None 195 | passcodeCompliantWithProfile: Optional[bool] = None 196 | hardwareEncryption: Optional[int] = None 197 | activationLockEnabled: Optional[bool] = None 198 | jailBreakDetected: Optional[bool] = None 199 | passcodeLockGracePeriodEnforcedSeconds: Optional[int] = None 200 | personalDeviceProfileCurrent: Optional[bool] = None 201 | lostModeEnabled: Optional[bool] = None 202 | lostModePersistent: Optional[bool] = None 203 | lostModeMessage: Optional[str] = None 204 | lostModePhoneNumber: Optional[str] = None 205 | lostModeFootnote: Optional[str] = None 206 | lostModeLocation: Optional[MobileDeviceSecurityLostModeLocation] = None 207 | 208 | 209 | class MobileDeviceEbook(BaseModel): 210 | """iOS devices only.""" 211 | 212 | model_config = ConfigDict(extra="allow") 213 | 214 | author: Optional[str] = None 215 | title: Optional[str] = None 216 | version: Optional[str] = None 217 | kind: Optional[str] = None 218 | managementState: Optional[str] = None 219 | 220 | 221 | class MobileDeviceNetwork(BaseModel): 222 | """iOS devices only.""" 223 | 224 | model_config = ConfigDict(extra="allow") 225 | 226 | cellularTechnology: Optional[str] = None 227 | voiceRoamingEnabled: Optional[bool] = None 228 | imei: Optional[str] = None 229 | iccid: Optional[str] = None 230 | meid: Optional[str] = None 231 | eid: Optional[str] = None 232 | carrierSettingsVersion: Optional[str] = None 233 | currentCarrierNetwork: Optional[str] = None 234 | currentMobileCountryCode: Optional[str] = None 235 | currentMobileNetworkCode: Optional[str] = None 236 | homeCarrierNetwork: Optional[str] = None 237 | homeMobileCountryCode: Optional[str] = None 238 | homeMobileNetworkCode: Optional[str] = None 239 | dataRoamingEnabled: Optional[bool] = None 240 | roaming: Optional[bool] = None 241 | personalHotspotEnabled: Optional[bool] = None 242 | phoneNumber: Optional[str] = None 243 | 244 | 245 | class MobileDeviceServiceSubscription(BaseModel): 246 | """iOS devices only.""" 247 | 248 | model_config = ConfigDict(extra="allow") 249 | 250 | carrierSettingsVersion: Optional[str] = None 251 | currentCarrierNetwork: Optional[str] = None 252 | currentMobileCountryCode: Optional[str] = None 253 | currentMobileNetworkCode: Optional[str] = None 254 | subscriberCarrierNetwork: Optional[str] = None 255 | eid: Optional[str] = None 256 | iccid: Optional[str] = None 257 | imei: Optional[str] = None 258 | dataPreferred: Optional[bool] = None 259 | roaming: Optional[bool] = None 260 | voicePreferred: Optional[bool] = None 261 | label: Optional[str] = None 262 | labelId: Optional[str] = None 263 | meid: Optional[str] = None 264 | phoneNumber: Optional[str] = None 265 | slot: Optional[str] = None 266 | 267 | 268 | class ProvisioningProfile(BaseModel): 269 | """iOS devices only.""" 270 | 271 | model_config = ConfigDict(extra="allow") 272 | 273 | displayName: Optional[str] = None 274 | uuid: Optional[str] = None 275 | expirationDate: Optional[datetime] = None 276 | 277 | 278 | class SharedUser(BaseModel): 279 | """iOS devices only.""" 280 | 281 | model_config = ConfigDict(extra="allow") 282 | 283 | managedAppleId: Optional[str] = None 284 | loggedIn: Optional[bool] = None 285 | dataToSync: Optional[bool] = None 286 | 287 | 288 | class MobileDevice(BaseModel): 289 | """Represents a full mobile device inventory record.""" 290 | 291 | model_config = ConfigDict(extra="allow") 292 | 293 | mobileDeviceId: Optional[str] = None 294 | deviceType: Optional[str] = None 295 | hardware: Optional[MobileDeviceHardware] = None 296 | userAndLocation: Optional[MobileDeviceUserAndLocation] = None 297 | purchasing: Optional[MobileDevicePurchasing] = None 298 | applications: Optional[List[MobileDeviceApplication]] = None 299 | certificates: Optional[List[MobileDeviceCertificate]] = None 300 | profiles: Optional[List[MobileDeviceProfile]] = None 301 | userProfiles: Optional[List[MobileDeviceUserProfile]] = None 302 | extensionAttributes: Optional[List[MobileDeviceExtensionAttribute]] = None 303 | general: Optional[MobileDeviceGeneral] = None 304 | security: Optional[MobileDeviceSecurity] = None 305 | ebooks: Optional[List[MobileDeviceEbook]] = None 306 | network: Optional[MobileDeviceNetwork] = None 307 | serviceSubscriptions: Optional[List[MobileDeviceServiceSubscription]] = None 308 | provisioningProfiles: Optional[List[ProvisioningProfile]] = None 309 | sharedUsers: Optional[List[SharedUser]] = None 310 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/pro/packages.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import ConfigDict 4 | 5 | from .. import BaseModel 6 | 7 | 8 | class Package(BaseModel): 9 | """Represents a full package record.""" 10 | 11 | model_config = ConfigDict(extra="allow") 12 | 13 | id: Optional[str] 14 | packageName: str 15 | fileName: str 16 | categoryId: str 17 | info: Optional[str] 18 | notes: Optional[str] 19 | priority: int 20 | osRequirements: Optional[str] 21 | fillUserTemplate: bool 22 | indexed: bool 23 | fillExistingUsers: bool 24 | swu: bool 25 | rebootRequired: bool 26 | selfHealNotify: bool 27 | selfHealingAction: Optional[str] 28 | osInstall: bool 29 | serialNumber: Optional[str] 30 | parentPackageId: Optional[str] 31 | basePath: Optional[str] 32 | suppressUpdates: bool 33 | cloudTransferStatus: str 34 | ignoreConflicts: bool 35 | suppressFromDock: bool 36 | suppressEula: bool 37 | suppressRegistration: bool 38 | installLanguage: Optional[str] 39 | md5: Optional[str] 40 | sha256: Optional[str] 41 | hashType: Optional[str] 42 | hashValue: Optional[str] 43 | size: Optional[str] 44 | osInstallerVersion: Optional[str] 45 | manifest: Optional[str] 46 | manifestFileName: Optional[str] 47 | format: Optional[str] 48 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .webhooks import ( 2 | ComputerAdded, 3 | ComputerCheckIn, 4 | ComputerInventoryCompleted, 5 | ComputerPolicyFinished, 6 | ComputerPushCapabilityChanged, 7 | DeviceAddedToDep, 8 | JssShutdown, 9 | JssStartup, 10 | MobileDeviceCheckIn, 11 | MobileDeviceEnrolled, 12 | MobileDevicePushSent, 13 | MobileDeviceUnEnrolled, 14 | PushSent, 15 | RestApiOperation, 16 | SmartGroupComputerMembershipChange, 17 | SmartGroupMobileDeviceMembershipChange, 18 | SmartGroupUserMembershipChange, 19 | ) 20 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/models/webhooks/webhooks.py: -------------------------------------------------------------------------------- 1 | from ipaddress import IPv4Address 2 | from typing import Literal, Optional, Union 3 | 4 | from pydantic import BaseModel, ConfigDict 5 | 6 | 7 | class WebhookModel(BaseModel): 8 | model_config = ConfigDict(extra="allow") 9 | 10 | 11 | class WebhookData(WebhookModel): 12 | """Attributes shared by all Event models.""" 13 | 14 | eventTimestamp: int 15 | id: int 16 | name: str 17 | 18 | 19 | class ComputerEvent(WebhookModel): 20 | alternateMacAddress: str 21 | building: str 22 | department: str 23 | deviceName: str 24 | emailAddress: str 25 | ipAddress: IPv4Address 26 | jssID: int 27 | macAddress: str 28 | model: str 29 | osBuild: str 30 | osVersion: str 31 | phone: str 32 | position: str 33 | realName: str 34 | reportedIpAddress: IPv4Address 35 | room: str 36 | serialNumber: str 37 | udid: str 38 | userDirectoryID: str 39 | username: str 40 | 41 | 42 | class MobileDeviceEvent(WebhookModel): 43 | bluetoothMacAddress: str 44 | deviceName: str 45 | icciID: str 46 | imei: str 47 | ipAddress: IPv4Address 48 | jssID: int 49 | model: str 50 | modelDisplay: str 51 | osBuild: str 52 | osVersion: str 53 | product: str 54 | room: str 55 | serialNumber: str 56 | udid: str 57 | userDirectoryID: str 58 | username: str 59 | version: str 60 | wifiMacAddress: str 61 | 62 | 63 | # COMPUTER ADDED 64 | 65 | 66 | class ComputerAddedWebhook(WebhookData): 67 | webhookEvent: Literal["ComputerAdded"] 68 | 69 | 70 | class ComputerAdded(WebhookModel): 71 | webhook: ComputerAddedWebhook 72 | event: ComputerEvent 73 | 74 | 75 | # COMPUTER CHECK-IN 76 | 77 | 78 | class ComputerCheckInWebhook(WebhookData): 79 | webhookEvent: Literal["ComputerCheckIn"] 80 | 81 | 82 | class ComputerCheckInEvent(WebhookModel): 83 | computer: ComputerEvent 84 | trigger: str 85 | username: str 86 | 87 | 88 | class ComputerCheckIn(WebhookModel): 89 | webhook: ComputerCheckInWebhook 90 | event: ComputerCheckInEvent 91 | 92 | 93 | # COMPUTER INVENTORY COMPLETED 94 | 95 | 96 | class ComputerInventoryCompletedWebhook(WebhookData): 97 | webhookEvent: Literal["ComputerInventoryCompleted"] 98 | 99 | 100 | class ComputerInventoryCompleted(WebhookModel): 101 | webhook: ComputerInventoryCompletedWebhook 102 | event: ComputerEvent 103 | 104 | 105 | # COMPUTER POLICY FINISHED 106 | 107 | 108 | class ComputerPolicyFinishedWebhook(WebhookData): 109 | webhookEvent: Literal["ComputerPolicyFinished"] 110 | 111 | 112 | class ComputerPolicyFinishedEvent(WebhookModel): 113 | computer: ComputerEvent 114 | policyId: int 115 | successful: bool 116 | 117 | 118 | class ComputerPolicyFinished(WebhookModel): 119 | webhook: ComputerPolicyFinishedWebhook 120 | event: ComputerPolicyFinishedEvent 121 | 122 | 123 | # COMPUTER PUSH CAPABILITY CHANGED 124 | 125 | 126 | class ComputerPushCapabilityChangedWebhook(WebhookData): 127 | webhookEvent: Literal["ComputerPushCapabilityChanged"] 128 | 129 | 130 | class ComputerPushCapabilityChanged(WebhookModel): 131 | webhook: ComputerPushCapabilityChangedWebhook 132 | event: ComputerEvent 133 | 134 | 135 | # DEVICE ADDED TO DEP 136 | 137 | 138 | class DeviceAddedToDepWebhook(WebhookData): 139 | webhookEvent: Literal["DeviceAddedToDEP"] 140 | 141 | 142 | class DeviceAddedToDepEvent(WebhookModel): 143 | assetTag: str 144 | description: str 145 | deviceAssignedDate: int 146 | deviceEnrollmentProgramInstanceId: int 147 | model: str 148 | serialNumber: str 149 | 150 | 151 | class DeviceAddedToDep(WebhookModel): 152 | webhook: DeviceAddedToDepWebhook 153 | event: DeviceAddedToDepEvent 154 | 155 | 156 | # JSS STARTUP/SHUTDOWN 157 | 158 | 159 | class JssStartupWebhook(WebhookData): 160 | webhookEvent: Literal["JSSStartup"] 161 | 162 | 163 | class JssShutdownWebhook(WebhookData): 164 | webhookEvent: Literal["JSSShutdown"] 165 | 166 | 167 | class JssStartupShutdownEvent(WebhookModel): 168 | hostAddress: str 169 | institution: str 170 | isClusterMaster: bool 171 | jssUrl: str 172 | webApplicationPath: str 173 | 174 | 175 | class JssStartup(WebhookModel): 176 | webhook: JssStartupWebhook 177 | event: JssStartupShutdownEvent 178 | 179 | 180 | class JssShutdown(WebhookModel): 181 | webhook: JssShutdownWebhook 182 | event: JssStartupShutdownEvent 183 | 184 | 185 | # MOBILE DEVICE CHECK-IN 186 | 187 | 188 | class MobileDeviceCheckInWebhook(WebhookData): 189 | webhookEvent: Literal["MobileDeviceCheckIn"] 190 | 191 | 192 | class MobileDeviceCheckIn(WebhookModel): 193 | webhook: MobileDeviceCheckInWebhook 194 | event: MobileDeviceEvent 195 | 196 | 197 | # MOBILE DEVICE ENROLLED 198 | 199 | 200 | class MobileDeviceEnrolledWebhook(WebhookData): 201 | webhookEvent: Literal["MobileDeviceEnrolled"] 202 | 203 | 204 | class MobileDeviceEnrolled(WebhookModel): 205 | webhook: MobileDeviceEnrolledWebhook 206 | event: MobileDeviceEvent 207 | 208 | 209 | # MOBILE DEVICE UNENROLLED 210 | 211 | 212 | class MobileDeviceUnEnrolledWebhook(WebhookData): 213 | webhookEvent: Literal["MobileDeviceUnEnrolled"] 214 | 215 | 216 | class MobileDeviceUnEnrolled(WebhookModel): 217 | webhook: MobileDeviceUnEnrolledWebhook 218 | event: MobileDeviceEvent 219 | 220 | 221 | # MOBILE DEVICE PUSH SENT 222 | 223 | 224 | class MobileDevicePushSentWebhook(WebhookData): 225 | webhookEvent: Literal["MobileDevicePushSent"] 226 | 227 | 228 | class MobileDevicePushSent(WebhookModel): 229 | webhook: MobileDevicePushSentWebhook 230 | event: MobileDeviceEvent 231 | 232 | 233 | # PUSH SENT 234 | 235 | 236 | class PushSentWebhook(WebhookData): 237 | webhookEvent: Literal["PushSent"] 238 | 239 | 240 | class PushSentEvent(WebhookModel): 241 | managementId: Union[int, str] 242 | type: str 243 | 244 | 245 | class PushSent(WebhookModel): 246 | webhook: PushSentWebhook 247 | event: PushSentEvent 248 | 249 | 250 | # REST API OPERATION 251 | 252 | 253 | class RestApiOperationWebhook(WebhookData): 254 | webhookEvent: Literal["RestAPIOperation"] 255 | 256 | 257 | class RestApiOperationEvent(WebhookModel): 258 | authorizedUsername: str 259 | objectID: int 260 | objectName: str 261 | objectTypeName: str 262 | operationSuccessful: bool 263 | restAPIOperationType: str 264 | 265 | 266 | class RestApiOperation(WebhookModel): 267 | webhook: RestApiOperationWebhook 268 | event: RestApiOperationEvent 269 | 270 | 271 | # COMPUTER SMART GROUP MEMBERSHIP CHANGE 272 | 273 | 274 | class SmartGroupComputerMembershipChangeWebhook(WebhookData): 275 | webhookEvent: Literal["SmartGroupComputerMembershipChange"] 276 | 277 | 278 | class SmartGroupComputerMembershipChangeEvent(WebhookModel): 279 | computer: Literal[True] 280 | groupAddedDevices: list 281 | groupAddedDevicesIds: list[int] 282 | groupRemovedDevices: list 283 | groupRemovedDevicesIds: list[int] 284 | jssid: int 285 | name: str 286 | smartGroup: Literal[True] 287 | 288 | 289 | class SmartGroupComputerMembershipChange(WebhookModel): 290 | webhook: SmartGroupComputerMembershipChangeWebhook 291 | event: SmartGroupComputerMembershipChangeEvent 292 | 293 | 294 | # MOBILE DEVICE SMART GROUP MEMBERSHIP CHANGE 295 | 296 | 297 | class SmartGroupMobileDeviceMembershipChangeWebhook(WebhookData): 298 | webhookEvent: Literal["SmartGroupMobileDeviceMembershipChange"] 299 | 300 | 301 | class SmartGroupMobileDeviceMembershipChangeEvent(WebhookModel): 302 | computer: Literal[False] 303 | groupAddedDevices: list[Optional[dict[str, str]]] 304 | groupAddedDevicesIds: list[int] 305 | groupRemovedDevices: list[Optional[dict[str, str]]] 306 | groupRemovedDevicesIds: list[int] 307 | jssid: int 308 | name: str 309 | smartGroup: Literal[True] 310 | 311 | 312 | class SmartGroupMobileDeviceMembershipChange(WebhookModel): 313 | webhook: SmartGroupMobileDeviceMembershipChangeWebhook 314 | event: SmartGroupMobileDeviceMembershipChangeEvent 315 | 316 | 317 | # USER SMART GROUP MEMBERSHIP CHANGE 318 | 319 | 320 | class SmartGroupUserMembershipChangeWebhook(WebhookData): 321 | webhookEvent: Literal["SmartGroupUserMembershipChange"] 322 | 323 | 324 | class SmartGroupUserMembershipChangeEvent(WebhookModel): 325 | groupAddedUserIds: list[int] 326 | groupRemovedUserIds: list[int] 327 | jssid: int 328 | name: str 329 | smartGroup: Literal[True] 330 | 331 | 332 | class SmartGroupUserMembershipChange(WebhookModel): 333 | webhook: SmartGroupUserMembershipChangeWebhook 334 | event: SmartGroupUserMembershipChangeEvent 335 | -------------------------------------------------------------------------------- /src/jamf_pro_sdk/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/src/jamf_pro_sdk/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import pytest 5 | 6 | from jamf_pro_sdk import ( 7 | JamfProClient, 8 | SessionConfig, 9 | logger_quick_setup, 10 | ) 11 | from jamf_pro_sdk.clients.auth import ApiClientCredentialsProvider 12 | 13 | # https://developer.jamf.com/developer-guide/docs/populating-dummy-data 14 | JAMF_PRO_HOST = os.getenv("JAMF_PRO_HOST") 15 | JAMF_PRO_CLIENT_ID = os.getenv("JAMF_PRO_CLIENT_ID") 16 | JAMF_PRO_CLIENT_SECRET = os.getenv("JAMF_PRO_CLIENT_SECRET") 17 | 18 | # Run pytest with '-s' to view logging output 19 | logger_quick_setup(logging.DEBUG) 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def jamf_client(): 24 | client = JamfProClient( 25 | server=JAMF_PRO_HOST, 26 | credentials=ApiClientCredentialsProvider( 27 | client_id=JAMF_PRO_CLIENT_ID, client_secret=JAMF_PRO_CLIENT_SECRET 28 | ), 29 | session_config=SessionConfig(timeout=30), 30 | ) 31 | 32 | # Retrieve an access token immediately after init 33 | client.get_access_token() 34 | 35 | return client 36 | -------------------------------------------------------------------------------- /tests/integration/test_classic_client_computers.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | def test_integration_classic_get_computers(jamf_client): 5 | result_list = jamf_client.classic_api.list_all_computers(subsets=["basic"]) 6 | 7 | # Select five records at random, read, and verify their IDs 8 | for _ in range(0, 5): 9 | listed_computer = random.choice(result_list) 10 | computer_read = jamf_client.classic_api.get_computer_by_id(listed_computer.id) 11 | 12 | assert computer_read.general.id == listed_computer.id 13 | assert computer_read.general.udid == listed_computer.udid 14 | assert computer_read.general.name == listed_computer.name 15 | assert computer_read.general.mac_address == listed_computer.mac_address 16 | -------------------------------------------------------------------------------- /tests/integration/test_client_auth.py: -------------------------------------------------------------------------------- 1 | from jamf_pro_sdk.models.client import AccessToken 2 | 3 | 4 | def test_integration_basic_auth(jamf_client): 5 | current_token = jamf_client.get_access_token() 6 | assert isinstance(current_token, AccessToken) 7 | assert not current_token.is_expired 8 | -------------------------------------------------------------------------------- /tests/integration/test_pro_client_computers.py: -------------------------------------------------------------------------------- 1 | # from jamf_pro_sdk.clients.pro_api.pagination import FilterField, SortField 2 | 3 | 4 | def test_integration_pro_computer_inventory_v1_default(jamf_client): 5 | # This test is only valid if the computer inventory is less than the max page size 6 | 7 | # Test at max page size to get full inventory count 8 | result_one_call = jamf_client.pro_api.get_computer_inventory_v1(page_size=2000) 9 | result_total_count = len(result_one_call) 10 | assert result_total_count > 1 11 | 12 | # Test paginated response matches full inventory count above 13 | result_paginated = jamf_client.pro_api.get_computer_inventory_v1(page_size=10) 14 | assert result_total_count == len(result_paginated) 15 | -------------------------------------------------------------------------------- /tests/integration/test_pro_client_mobile_devices.py: -------------------------------------------------------------------------------- 1 | # from jamf_pro_sdk.clients.pro_api.pagination import FilterField, SortField 2 | 3 | 4 | def test_integration_pro_computer_inventory_v1_default(jamf_client): 5 | # This test is only valid if the computer inventory is less than the max page size 6 | 7 | # Test at max page size to get full inventory count 8 | result_one_call = jamf_client.pro_api.get_mobile_device_inventory_v2(page_size=2000) 9 | result_total_count = len(result_one_call) 10 | assert result_total_count > 1 11 | 12 | # Test paginated response matches full inventory count above 13 | result_paginated = jamf_client.pro_api.get_mobile_device_inventory_v2(page_size=10) 14 | assert result_total_count == len(result_paginated) 15 | -------------------------------------------------------------------------------- /tests/integration/test_pro_client_packages.py: -------------------------------------------------------------------------------- 1 | # from jamf_pro_sdk.clients.pro_api.pagination import FilterField, SortField 2 | 3 | 4 | def test_integration_pro_packages_v1_default(jamf_client): 5 | # This test is only valid if the computer inventory is less than the max page size 6 | 7 | # Test at max page size to get full inventory count 8 | result_one_call = jamf_client.pro_api.get_packages_v1() 9 | result_total_count = len(result_one_call) 10 | assert result_total_count > 1 11 | 12 | # Test paginated response matches full inventory count above 13 | result_paginated = jamf_client.pro_api.get_packages_v1(page_size=10) 14 | assert result_total_count == len(result_paginated) 15 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/tests/unit/conftest.py -------------------------------------------------------------------------------- /tests/unit/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macadmins/jamf-pro-sdk-python/4370d6bbbab7fa7fba67ae7d4e4a7c58103cf3fd/tests/unit/models/__init__.py -------------------------------------------------------------------------------- /tests/unit/models/test_models_classic_categories.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from deepdiff import DeepDiff 4 | from src.jamf_pro_sdk.models.classic.categories import ClassicCategory 5 | 6 | CATEGORY_JSON = {"category": {"id": 1, "name": "Test Category", "priority": 1}} 7 | 8 | 9 | def test_category_model_parsings(): 10 | """Verify select attributes across the ComputerGroup model.""" 11 | category = ClassicCategory.model_validate(CATEGORY_JSON["category"]) 12 | 13 | assert category is not None # mypy 14 | assert category.name == "Test Category" 15 | assert category.priority == 1 16 | assert category.id == 1 17 | 18 | 19 | def test_category_model_json_output_matches_input(): 20 | category = ClassicCategory.model_validate(CATEGORY_JSON["category"]) 21 | serialized_output = json.loads(category.model_dump_json(exclude_none=True)) 22 | 23 | diff = DeepDiff(CATEGORY_JSON["category"], serialized_output, ignore_order=True) 24 | 25 | assert not diff 26 | -------------------------------------------------------------------------------- /tests/unit/models/test_models_classic_computer_groups.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from deepdiff import DeepDiff 4 | from src.jamf_pro_sdk.models.classic.computer_groups import ClassicComputerGroup 5 | 6 | COMPUTER_GROUP_JSON = { 7 | "computer_group": { 8 | "id": 1, 9 | "name": "All Managed Clients", 10 | "is_smart": True, 11 | "site": {"id": -1, "name": "None"}, 12 | "criteria": [ 13 | { 14 | "name": "Operating System", 15 | "priority": 0, 16 | "and_or": "and", 17 | "search_type": "not like", 18 | "value": "server", 19 | "opening_paren": False, 20 | "closing_paren": False, 21 | }, 22 | { 23 | "name": "Application Title", 24 | "priority": 1, 25 | "and_or": "and", 26 | "search_type": "is not", 27 | "value": "Server.app", 28 | "opening_paren": False, 29 | "closing_paren": False, 30 | }, 31 | ], 32 | "computers": [ 33 | { 34 | "id": 123, 35 | "name": "Peccy's MacBook Pro", 36 | "mac_address": "AA:BB:CC:DD:EE:FF", 37 | "alt_mac_address": "", 38 | "serial_number": "C02XXXXXXHD1", 39 | }, 40 | { 41 | "id": 456, 42 | "name": "Jeff's MacBook Pro", 43 | "mac_address": "00:11:22:33:44:55", 44 | "alt_mac_address": "", 45 | "serial_number": "C02XXXXXXHD2", 46 | }, 47 | ], 48 | } 49 | } 50 | 51 | 52 | def test_computer_group_model_parsing(): 53 | """Verify select attributes across the ComputerGroup model.""" 54 | group = ClassicComputerGroup.model_validate(COMPUTER_GROUP_JSON["computer_group"]) 55 | 56 | assert group is not None # mypy 57 | assert group.criteria is not None # mypy 58 | assert group.computers is not None # mypy 59 | 60 | assert group.id == 1 61 | assert group.is_smart is True 62 | 63 | assert len(group.criteria) == 2 64 | assert len(group.computers) == 2 65 | 66 | assert group.criteria[0].name == "Operating System" 67 | assert group.criteria[0].search_type == "not like" 68 | assert group.criteria[0].value == "server" 69 | 70 | assert group.criteria[1].priority == 1 71 | assert group.criteria[1].and_or == "and" 72 | assert group.criteria[1].opening_paren is False 73 | 74 | assert group.computers[0].id == 123 75 | assert group.computers[0].name == "Peccy's MacBook Pro" 76 | 77 | assert group.computers[1].id == 456 78 | assert group.computers[1].mac_address == "00:11:22:33:44:55" 79 | 80 | 81 | def test_computer_model_json_output_matches_input(): 82 | computer = ClassicComputerGroup.model_validate(COMPUTER_GROUP_JSON["computer_group"]) 83 | serialized_output = json.loads(computer.model_dump_json(exclude_none=True)) 84 | 85 | diff = DeepDiff(COMPUTER_GROUP_JSON["computer_group"], serialized_output, ignore_order=True) 86 | 87 | assert not diff 88 | -------------------------------------------------------------------------------- /tests/unit/models/test_models_classic_network_segments.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from deepdiff import DeepDiff 4 | from src.jamf_pro_sdk.models.classic.network_segments import ClassicNetworkSegment 5 | 6 | NETWORK_SEGMENT_JSON = { 7 | "network_segment": { 8 | "id": 1, 9 | "name": "Test Network", 10 | "starting_address": "192.168.1.31", 11 | "ending_address": "192.168.7.255", 12 | "distribution_point": "Cloud Distribution Point", 13 | "url": "https://use1-jcds.services.jamfcloud.com/download/66d8960449b44a668c38e4dddbe094d7", 14 | "building": "Test Building", 15 | "department": "Test Department", 16 | } 17 | } 18 | 19 | 20 | def test_network_segment_model_parsings(): 21 | """Verify select attributes across the NetworkSegment model.""" 22 | network_segment = ClassicNetworkSegment.model_validate(NETWORK_SEGMENT_JSON["network_segment"]) 23 | 24 | assert network_segment is not None # mypy 25 | assert network_segment.name == "Test Network" 26 | assert network_segment.starting_address == "192.168.1.31" 27 | assert network_segment.ending_address == "192.168.7.255" 28 | assert network_segment.id == 1 29 | assert network_segment.distribution_point == "Cloud Distribution Point" 30 | assert ( 31 | network_segment.url 32 | == "https://use1-jcds.services.jamfcloud.com/download/66d8960449b44a668c38e4dddbe094d7" 33 | ) 34 | assert network_segment.building == "Test Building" 35 | assert network_segment.department == "Test Department" 36 | 37 | 38 | def test_network_segment_model_json_output_matches_input(): 39 | network_segment = ClassicNetworkSegment.model_validate(NETWORK_SEGMENT_JSON["network_segment"]) 40 | serialized_output = json.loads(network_segment.model_dump_json(exclude_none=True)) 41 | 42 | diff = DeepDiff(NETWORK_SEGMENT_JSON["network_segment"], serialized_output, ignore_order=True) 43 | 44 | assert not diff 45 | -------------------------------------------------------------------------------- /tests/unit/models/test_models_classic_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from pytest import raises 4 | from src.jamf_pro_sdk.models.classic import convert_datetime_to_jamf_iso, remove_fields 5 | 6 | 7 | def test_convert_datetime_to_jamf_iso(): 8 | dt = datetime(2023, 1, 1, 12, 30, 1, 321000, tzinfo=timezone.utc) 9 | assert convert_datetime_to_jamf_iso(dt) == "2023-01-01T12:30:01.321+0000" 10 | 11 | 12 | def test_convert_datetime_to_jamf_iso_no_tz(): 13 | dt = datetime(2023, 1, 1, 12, 30, 1, 321000) 14 | with raises(ValueError): 15 | convert_datetime_to_jamf_iso(dt) 16 | 17 | 18 | def test_remove_fields(): 19 | data = { 20 | "general": {"id": 123, "remote_management": {}}, 21 | "location": {}, 22 | "hardware": { 23 | "filevault2_users": ["admin"], 24 | }, 25 | "extension_attributes": [{}, {"id": 1, "value": "foo"}], 26 | "certificates": [], 27 | } 28 | 29 | cleaned_data = remove_fields(data) 30 | 31 | assert cleaned_data == { 32 | "general": {"id": 123}, 33 | "hardware": { 34 | "filevault2_users": ["admin"], 35 | }, 36 | "extension_attributes": [{"id": 1, "value": "foo"}], 37 | } 38 | -------------------------------------------------------------------------------- /tests/unit/test_pro_api_expressions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from src.jamf_pro_sdk.clients.pro_api.pagination import ( 4 | # FilterEntry, 5 | # FilterExpression, 6 | FilterField, 7 | # SortExpression, 8 | SortField, 9 | filter_group, 10 | ) 11 | 12 | STRF_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" 13 | 14 | 15 | def test_basic_filter_expression(): 16 | expression = FilterField("id").eq(1) 17 | assert str(expression) == "id==1" 18 | 19 | 20 | def test_multiple_filter_expression(): 21 | expression1 = FilterField("expires").gte( 22 | datetime(year=2023, month=9, day=11, hour=14).strftime(STRF_TIME_FORMAT) 23 | ) & FilterField("expires").lte( 24 | datetime(year=2023, month=9, day=11, hour=15).strftime(STRF_TIME_FORMAT) 25 | ) 26 | 27 | assert str(expression1) == "expires>=2023-09-11T14:00:00Z;expires<=2023-09-11T15:00:00Z" 28 | 29 | 30 | def test_in_out_filter_expressions(): 31 | expression1 = FilterField("status").is_in(["PENDING", "ACKNOWLEDGED"]) 32 | assert str(expression1) == "status=in=(PENDING,ACKNOWLEDGED)" 33 | 34 | expression2 = FilterField("status").not_in(["NOT_NOW", "ERROR"]) 35 | assert str(expression2) == "status=out=(NOT_NOW,ERROR)" 36 | 37 | 38 | def test_jamf_dev_docs_example_filter_expression(): 39 | # TODO: Spaces and special character handling using quotes 40 | expression = filter_group( 41 | FilterField("general.barcode1").eq("Sample") | FilterField("general.barcode2").eq("Sample") 42 | ) & FilterField("general.assetTag").gt(20) 43 | 44 | # assert ( 45 | # str(expression) 46 | # == '(general.barcode1=="Sample",general.barcode2=="Sample");general.assetTag>"20"' 47 | # ) 48 | assert ( 49 | str(expression) == "(general.barcode1==Sample,general.barcode2==Sample);general.assetTag>20" 50 | ) 51 | 52 | 53 | def test_sort_field_expressions(): 54 | expression1 = SortField("name").asc() 55 | assert str(expression1) == "name:asc" 56 | 57 | expression2 = SortField("id").desc() 58 | assert str(expression2) == "id:desc" 59 | 60 | expression3 = SortField("date").desc() & SortField("name").asc() 61 | assert str(expression3) == "date:desc,name:asc" 62 | -------------------------------------------------------------------------------- /tests/unit/test_webhooks_faker.py: -------------------------------------------------------------------------------- 1 | import re 2 | from ipaddress import IPv4Address 3 | 4 | from jamf_pro_sdk.clients.webhooks import get_webhook_generator 5 | from jamf_pro_sdk.models.webhooks import ( 6 | ComputerAdded, 7 | ComputerCheckIn, 8 | ComputerInventoryCompleted, 9 | ComputerPolicyFinished, 10 | ComputerPushCapabilityChanged, 11 | DeviceAddedToDep, 12 | JssShutdown, 13 | JssStartup, 14 | MobileDeviceCheckIn, 15 | MobileDeviceEnrolled, 16 | MobileDevicePushSent, 17 | MobileDeviceUnEnrolled, 18 | PushSent, 19 | RestApiOperation, 20 | SmartGroupComputerMembershipChange, 21 | SmartGroupMobileDeviceMembershipChange, 22 | SmartGroupUserMembershipChange, 23 | ) 24 | from jamf_pro_sdk.models.webhooks.webhooks import ComputerEvent 25 | 26 | MAC_REGEX = re.compile(r"^(?:[0-9A-Fa-f]{2}:){5}(?:[0-9A-Fa-f]{2})$") 27 | SERIAL_REGEX = re.compile(r"^[0-9A-Za-z]{10}$") 28 | # Only assert for uppercase UUIDs 29 | UUID_REGEX = re.compile(r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$") 30 | 31 | 32 | def test_webhooks_computer_added(): 33 | generator = get_webhook_generator(ComputerAdded) 34 | computer = generator.build() 35 | 36 | assert isinstance(computer, ComputerAdded) 37 | assert computer.webhook.webhookEvent == "ComputerAdded" 38 | 39 | # These fields will not be re-tested for any other events that share the same type 40 | assert isinstance(computer.event.ipAddress, IPv4Address) 41 | assert isinstance(computer.event.reportedIpAddress, IPv4Address) 42 | assert MAC_REGEX.match(computer.event.alternateMacAddress) 43 | assert MAC_REGEX.match(computer.event.macAddress) 44 | assert SERIAL_REGEX.match(computer.event.serialNumber) 45 | assert UUID_REGEX.match(computer.event.udid) 46 | 47 | 48 | def test_webhooks_computer_checkin(): 49 | generator = get_webhook_generator(ComputerCheckIn) 50 | computer = generator.build() 51 | 52 | assert isinstance(computer, ComputerCheckIn) 53 | assert computer.webhook.webhookEvent == "ComputerCheckIn" 54 | # Computer data is nested in this event type 55 | assert isinstance(computer.event.computer, ComputerEvent) 56 | 57 | 58 | def test_webhooks_computer_inventory_completed(): 59 | generator = get_webhook_generator(ComputerInventoryCompleted) 60 | computer = generator.build() 61 | 62 | assert isinstance(computer, ComputerInventoryCompleted) 63 | assert computer.webhook.webhookEvent == "ComputerInventoryCompleted" 64 | 65 | 66 | def test_webhooks_computer_policy_finished(): 67 | generator = get_webhook_generator(ComputerPolicyFinished) 68 | computer = generator.build() 69 | 70 | assert isinstance(computer, ComputerPolicyFinished) 71 | assert computer.webhook.webhookEvent == "ComputerPolicyFinished" 72 | # Computer data is nested in this event type 73 | assert isinstance(computer.event.computer, ComputerEvent) 74 | 75 | 76 | def test_webhooks_computer_push_capability_changed(): 77 | generator = get_webhook_generator(ComputerPushCapabilityChanged) 78 | computer = generator.build() 79 | 80 | assert isinstance(computer, ComputerPushCapabilityChanged) 81 | assert computer.webhook.webhookEvent == "ComputerPushCapabilityChanged" 82 | 83 | 84 | def test_webhooks_device_added_to_dep(): 85 | generator = get_webhook_generator(DeviceAddedToDep) 86 | dep_event = generator.build() 87 | 88 | assert isinstance(dep_event, DeviceAddedToDep) 89 | assert dep_event.webhook.webhookEvent == "DeviceAddedToDEP" 90 | 91 | 92 | def test_webhooks_jss_startup(): 93 | generator = get_webhook_generator(JssStartup) 94 | startup_event = generator.build() 95 | 96 | assert isinstance(startup_event, JssStartup) 97 | assert startup_event.webhook.webhookEvent == "JSSStartup" 98 | 99 | 100 | def test_webhooks_jss_shutdown(): 101 | generator = get_webhook_generator(JssShutdown) 102 | shutdown_event = generator.build() 103 | 104 | assert isinstance(shutdown_event, JssShutdown) 105 | assert shutdown_event.webhook.webhookEvent == "JSSShutdown" 106 | 107 | 108 | def test_webhooks_mobile_device_checkin(): 109 | generator = get_webhook_generator(MobileDeviceCheckIn) 110 | mobile_device = generator.build() 111 | 112 | assert isinstance(mobile_device, MobileDeviceCheckIn) 113 | assert mobile_device.webhook.webhookEvent == "MobileDeviceCheckIn" 114 | 115 | # These fields will not be re-tested for any other events that share the same type 116 | assert isinstance(mobile_device.event.ipAddress, IPv4Address) 117 | assert MAC_REGEX.match(mobile_device.event.bluetoothMacAddress) 118 | assert SERIAL_REGEX.match(mobile_device.event.serialNumber) 119 | assert UUID_REGEX.match(mobile_device.event.udid) 120 | assert MAC_REGEX.match(mobile_device.event.wifiMacAddress) 121 | 122 | 123 | def test_webhooks_mobile_device_enrolled(): 124 | generator = get_webhook_generator(MobileDeviceEnrolled) 125 | mobile_device = generator.build() 126 | 127 | assert isinstance(mobile_device, MobileDeviceEnrolled) 128 | assert mobile_device.webhook.webhookEvent == "MobileDeviceEnrolled" 129 | 130 | 131 | def test_webhooks_mobile_device_unenrolled(): 132 | generator = get_webhook_generator(MobileDeviceUnEnrolled) 133 | mobile_device = generator.build() 134 | 135 | assert isinstance(mobile_device, MobileDeviceUnEnrolled) 136 | assert mobile_device.webhook.webhookEvent == "MobileDeviceUnEnrolled" 137 | 138 | 139 | def test_webhooks_mobile_device_push_sent(): 140 | generator = get_webhook_generator(MobileDevicePushSent) 141 | mobile_device = generator.build() 142 | 143 | assert isinstance(mobile_device, MobileDevicePushSent) 144 | assert mobile_device.webhook.webhookEvent == "MobileDevicePushSent" 145 | 146 | 147 | def test_webhooks_push_sent(): 148 | generator = get_webhook_generator(PushSent) 149 | push_event = generator.build() 150 | 151 | assert isinstance(push_event, PushSent) 152 | assert push_event.webhook.webhookEvent == "PushSent" 153 | 154 | 155 | def test_webhooks_rest_api_op(): 156 | generator = get_webhook_generator(RestApiOperation) 157 | rest_api_op = generator.build() 158 | 159 | assert isinstance(rest_api_op, RestApiOperation) 160 | assert rest_api_op.webhook.webhookEvent == "RestAPIOperation" 161 | 162 | 163 | def test_webhooks_smart_group_computer_membership_change(): 164 | generator = get_webhook_generator(SmartGroupComputerMembershipChange) 165 | group_change = generator.build() 166 | 167 | assert isinstance(group_change, SmartGroupComputerMembershipChange) 168 | assert group_change.webhook.webhookEvent == "SmartGroupComputerMembershipChange" 169 | assert isinstance(group_change.event.computer, bool) and group_change.event.computer is True 170 | assert isinstance(group_change.event.smartGroup, bool) and group_change.event.smartGroup is True 171 | 172 | 173 | def test_webhooks_smart_group_mobile_device_membership_change(): 174 | generator = get_webhook_generator(SmartGroupMobileDeviceMembershipChange) 175 | group_change = generator.build() 176 | 177 | assert isinstance(group_change, SmartGroupMobileDeviceMembershipChange) 178 | assert group_change.webhook.webhookEvent == "SmartGroupMobileDeviceMembershipChange" 179 | assert isinstance(group_change.event.computer, bool) and group_change.event.computer is False 180 | assert isinstance(group_change.event.smartGroup, bool) and group_change.event.smartGroup is True 181 | 182 | 183 | def test_webhooks_smart_group_user_membership_change(): 184 | generator = get_webhook_generator(SmartGroupUserMembershipChange) 185 | group_change = generator.build() 186 | 187 | assert isinstance(group_change, SmartGroupUserMembershipChange) 188 | assert group_change.webhook.webhookEvent == "SmartGroupUserMembershipChange" 189 | assert not hasattr(group_change.event, "computer") 190 | assert isinstance(group_change.event.smartGroup, bool) and group_change.event.smartGroup is True 191 | -------------------------------------------------------------------------------- /tests/unit/utils.py: -------------------------------------------------------------------------------- 1 | def remove_whitespaces_newlines(string: str): 2 | return "".join([i.strip() for i in string.split("\n")]) 3 | --------------------------------------------------------------------------------