commit 5b0c72dbefe9d105089f03b5af4de9783c615b52 Author: Adam Goldsmith Date: Sat Nov 16 00:26:00 2024 -0500 Initial Commit Really should have added this to version control earlier, but was trying to fix all the pre-commit issues first... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5699150 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-toml + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.3 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.5.2 + hooks: + - id: uv-lock + + - repo: https://github.com/tox-dev/pyproject-fmt + rev: v2.5.0 + hooks: + - id: pyproject-fmt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a20c46 --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +Copyright 2024 Adam Goldsmith + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d346e1 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Python UniFi Access API Client + +Unofficial typed wrapper for the public UniFi Access API. +Currently written against V2.2.10 of the [API Reference](https://core-config-gfoz.uid.alpha.ui.com/configs/unifi-access/api_reference.pdf). + +## Completion Status +See [docs/completion.md](docs/completion.md) + +## Disclaimer + +This library is not affiliated with or endorsed by Ubiquiti Networks Inc. +It probably contains at least some errors or mismatch with the actual API behaviour. +Feel free to open issues or submit pull requests. diff --git a/docs/api_reference/client.md b/docs/api_reference/client.md new file mode 100644 index 0000000..32da584 --- /dev/null +++ b/docs/api_reference/client.md @@ -0,0 +1,3 @@ +::: unifi_access.AccessClient + options: + show_root_heading: true diff --git a/docs/api_reference/schemas/03_user.md b/docs/api_reference/schemas/03_user.md new file mode 100644 index 0000000..dfba2d6 --- /dev/null +++ b/docs/api_reference/schemas/03_user.md @@ -0,0 +1,3 @@ +# 3. User + +::: unifi_access.schemas._user diff --git a/docs/api_reference/schemas/04_visitor.md b/docs/api_reference/schemas/04_visitor.md new file mode 100644 index 0000000..5caa8c8 --- /dev/null +++ b/docs/api_reference/schemas/04_visitor.md @@ -0,0 +1,3 @@ +# 4. Visitor + +::: unifi_access.schemas._visitor diff --git a/docs/api_reference/schemas/05_access_policy.md b/docs/api_reference/schemas/05_access_policy.md new file mode 100644 index 0000000..d30bf72 --- /dev/null +++ b/docs/api_reference/schemas/05_access_policy.md @@ -0,0 +1,2 @@ +# 5. Access Policy +::: unifi_access.schemas._access_policy diff --git a/docs/api_reference/schemas/06_credential.md b/docs/api_reference/schemas/06_credential.md new file mode 100644 index 0000000..1407a11 --- /dev/null +++ b/docs/api_reference/schemas/06_credential.md @@ -0,0 +1,3 @@ +# 6. Credential + +::: unifi_access.schemas._credential diff --git a/docs/api_reference/schemas/07_space.md b/docs/api_reference/schemas/07_space.md new file mode 100644 index 0000000..4381575 --- /dev/null +++ b/docs/api_reference/schemas/07_space.md @@ -0,0 +1,3 @@ +# 7. Space + +::: unifi_access.schemas._space diff --git a/docs/api_reference/schemas/08_device.md b/docs/api_reference/schemas/08_device.md new file mode 100644 index 0000000..196e059 --- /dev/null +++ b/docs/api_reference/schemas/08_device.md @@ -0,0 +1,3 @@ +# 8. Device + +::: unifi_access.schemas._device diff --git a/docs/api_reference/schemas/09_system_log.md b/docs/api_reference/schemas/09_system_log.md new file mode 100644 index 0000000..3bd9f48 --- /dev/null +++ b/docs/api_reference/schemas/09_system_log.md @@ -0,0 +1,3 @@ +# 9. System Log + +::: unifi_access.schemas._system_log diff --git a/docs/api_reference/schemas/10_identity.md b/docs/api_reference/schemas/10_identity.md new file mode 100644 index 0000000..5f23ea0 --- /dev/null +++ b/docs/api_reference/schemas/10_identity.md @@ -0,0 +1,3 @@ +# 10. UniFi Identity + +::: unifi_access.schemas._identity diff --git a/docs/api_reference/schemas/index.md b/docs/api_reference/schemas/index.md new file mode 100644 index 0000000..7690d2a --- /dev/null +++ b/docs/api_reference/schemas/index.md @@ -0,0 +1,2 @@ +# Schemas +::: unifi_access.schemas._base diff --git a/docs/completion.md b/docs/completion.md new file mode 100644 index 0000000..f6ff879 --- /dev/null +++ b/docs/completion.md @@ -0,0 +1,102 @@ +# Completion + +* AccessClient -> implemented in the client +* offline/examples test -> has offline tests based on examples in the API reference pdf +* live test -> is used in a test that runs against a real instance + +| Section/Endpoint | AccessClient | offline/examples test | live test | +|------------------------------------------------------------|--------------------|-----------------------|--------------------| +| 3. User | :white_check_mark: | :white_check_mark: | | +| └─ 3.2 User Registration | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.3 Update User | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.4 Fetch User | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.5 Fetch All Users | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.6 Assign Access Policy to User | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.7 Assign NFC Card to User | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.8 Unassign NFC Card from User | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.9 Assign PIN Code to User | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.10 Unassign PIN Code from User | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.11 Create User Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.12 Fetch All User Groups | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.13 Fetch User Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.14 Update User Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.15 Delete User Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.16 Assign User to User Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.17 Unassign User from User Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.18 Fetch Users in a User Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 3.19 Fetch All Users in a User Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.20 Fetch the Access Policies Assigned to a User | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.21 Assign Access Policy to User Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 3.22 Fetch the Access Policies Assigned to a User Group | :white_check_mark: | :white_check_mark: | :x: | +| 4. Visitor | :white_check_mark: | :white_check_mark: | | +| └─ 4.2 Create Visitor | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 4.3 Fetch Visitor | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 4.4 Fetch All Visitors | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 4.5 Update Visitor | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 4.6 Delete Visitor | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 4.7 Assign NFC Card To Visitor | :white_check_mark: | :white_check_mark: | :x: | +| └─ 4.8 Unassign NFC Card From Visitor | :white_check_mark: | :white_check_mark: | :x: | +| └─ 4.9 Assign PIN Code To Visitor | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 4.10 Unassign PIN Code From Visitor | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| 5. Access Policy | :white_check_mark: | :white_check_mark: | | +| └─ 5.2 Create Access Policy | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.3 Update Access Policy | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.4 Delete Access Policy | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.5 Fetch Access Policy | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.6 Fetch All Access Policies | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.8 Create Holiday Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.9 Update Holiday Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.10 Delete Holiday Group | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.11 Fetch Holiday Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.12 Fetch All Holiday Groups | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.14 Create Schedule | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| └─ 5.15 Update Schedule | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.16 Fetch Schedule | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.17 Fetch All Schedules | :white_check_mark: | :white_check_mark: | :x: | +| └─ 5.18 Delete Schedule | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| 6. Credential | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.1 Generate PIN Code | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.2 Enroll NFC Card | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.3 Fetch NFC Card Enrollment Status | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.4 Remove a Session Created for NFC Card Enrollment | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.7 Fetch NFC Card | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.8 Fetch All NFC Cards | :white_check_mark: | :white_check_mark: | :x: | +| └─ 6.9 Delete NFC Card | :white_check_mark: | :white_check_mark: | :x: | +| 7. Space | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.1 Fetch Door Group Topology | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.2 Create Door Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.3 Fetch Door Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.4 Update Door Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.5 Fetch All Door Groups | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.6 Delete Door Group | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.7 Fetch Door | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.8 Fetch All Doors | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.9 Remote Door Unlocking | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.10 Set Temporary Door Locking Rule | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.11 Fetch Door Locking Rule | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.12 Set Door Emergency Status | :white_check_mark: | :white_check_mark: | :x: | +| └─ 7.13 Fetch Door Emergency Status | :white_check_mark: | :white_check_mark: | :x: | +| 8. Device | :white_check_mark: | :white_check_mark: | :x: | +| └─ 8.1 Fetch Devices | :white_check_mark: | :white_check_mark: | :x: | +| 9. System Log | :white_check_mark: | | :x: | +| └─ 9.2 Fetch System Logs | :white_check_mark: | :white_check_mark: | :x: | +| └─ 9.3 Export System Logs | :x: | :white_check_mark: | :x: | +| └─ 9.4 Fetch Resources in System Logs | :x: | :x: | :x: | +| └─ 9.5 Fetch Static Resources in System Logs | :x: | :x: | :x: | +| 10. UniFi Identity | :white_check_mark: | :white_check_mark: | :x: | +| └─ 10.1 Send UniFi Identity Invitations | :white_check_mark: | :white_check_mark: | :x: | +| └─ 10.2 Fetch Available Resources | :white_check_mark: | :white_check_mark: | :x: | +| └─ 10.3 Assign Resources to Users | :white_check_mark: | :white_check_mark: | :x: | +| └─ 10.4 Fetch Resources Assigned to Users | :white_check_mark: | :white_check_mark: | :x: | +| └─ 10.5 Assign Resources to User Groups | :white_check_mark: | :white_check_mark: | :x: | +| └─ 10.6 Fetch the Resources Assigned to User Groups | :white_check_mark: | :white_check_mark: | :x: | +| 11. Notification | :x: | :x: | :x: | +| └─ 11.1 Fetch Notifications [WebSocket] | :x: | :x: | :x: | +| └─ 11.2 List of Supported Webhook Events [Webhook] | :x: | :x: | :x: | +| └─ 11.3 Fetch Webhook Endpoints List [Webhook] | :x: | :x: | :x: | +| └─ 11.4 Add Webhook Endpoints [Webhook] | :x: | :x: | :x: | +| └─ 11.5 Update Webhook Endpoints [Webhook] | :x: | :x: | :x: | +| └─ 11.6 Delete Webhook Endpoints [Webhook] | :x: | :x: | :x: | +| 12. API Server | :x: | :x: | :x: | +| └─ 12.1 Upload HTTPS Certificate | :x: | :x: | :x: | +| └─ 12.2 Delete HTTPS Certificate | :x: | :x: | :x: | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..612c7a5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +--8<-- "README.md" diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..6161306 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,8 @@ +.md-nav--secondary > .md-nav__list > .md-nav__item > .md-nav { + display: none; +} + +.md-nav--secondary > .md-nav__list > .md-nav__item:has(.md-nav__link--active) > .md-nav + { + display: initial; +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..63089d3 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,58 @@ +site_name: Python UniFi Access Client + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + show_if_no_docstring: true + group_by_category: true + members_order: source + show_symbol_type_heading: true + show_root_toc_entry: false + show_symbol_type_toc: true + docstring_style: sphinx + merge_init_into_class: true + show_signature_annotations: true + separate_signature: true + signature_crossrefs: true + +theme: + name: material + features: + - toc.follow + - navigation.tracking + - navigation.indexes + - navigation.expand + - navigation.sections + + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to system preference + +watch: + - src + +markdown_extensions: + - tables + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.snippets + - pymdownx.emoji + +extra_css: + - stylesheets/extra.css diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f66c0fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,95 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling" ] + +[project] +name = "unifi-access" +version = "0.1.0" +description = "Typed wrapper for the Unifi Access Public API" +readme = "README.md" +license = { text = "ISC" } +authors = [ + { name = "Adam Goldsmith", email = "contact@adamgoldsmith.name" }, +] +requires-python = ">=3.11" +classifiers = [ + "License :: OSI Approved :: ISC License (ISCL)", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "pydantic>=2.9.2", + "requests>=2.32.3", +] + +[dependency-groups] +dev = [ + "mypy>=1.13", + "pytest>=8.3.3", + "responses>=0.25.3", + "ruff>=0.7.1", + "types-requests>=2.32.0.20241016", +] +docs = [ + "mkdocs-material>=9.5.42", + "mkdocstrings[python]>=0.26.2", +] + +[tool.ruff] +line-length = 88 + +lint.select = [ + "A", + "B", + "C4", + "E4", + "E7", + "E9", + "F", + "FIX003", + "FURB", + "I", + "INP", + "ISC", + "LOG", + "PERF", + "PIE", + "PL", + "PT", + "PTH", + "Q", + "RSE", + "SIM", + "T20", + "TCH", + "UP", +] +lint.ignore = [ "ISC001" ] + +[tool.pyproject-fmt] +indent = 4 + +[tool.pytest.ini_options] +filterwarnings = [ "ignore::urllib3.exceptions.InsecureRequestWarning" ] + +[tool.mypy] +plugins = [ "pydantic.mypy" ] + +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = true +disallow_any_generics = true +check_untyped_defs = true +no_implicit_reexport = true +disallow_untyped_defs = true + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.uv] +default-groups = [ "dev", "docs" ] diff --git a/src/unifi_access/__init__.py b/src/unifi_access/__init__.py new file mode 100644 index 0000000..ab61958 --- /dev/null +++ b/src/unifi_access/__init__.py @@ -0,0 +1,9 @@ +""" +Wrapper for Unifi Access API. + +See the [official API Reference](https://core-config-gfoz.uid.alpha.ui.com/configs/unifi-access/api_reference.pdf) for more details. +""" + +from ._client import AccessClient, Response, ResponseCode + +__all__ = ["AccessClient", "Response", "ResponseCode"] diff --git a/src/unifi_access/_client.py b/src/unifi_access/_client.py new file mode 100644 index 0000000..a6ebb07 --- /dev/null +++ b/src/unifi_access/_client.py @@ -0,0 +1,1293 @@ +import datetime +from collections.abc import Iterable, Sequence +from enum import StrEnum, auto +from typing import Any, Generic, Literal, Never, Self, TypeVar + +import requests +from pydantic import ( + ConfigDict, + Field, + TypeAdapter, +) +from typing_extensions import TypeAliasType + +from unifi_access.schemas import ( + AccessPolicy, + AccessPolicyId, + ActorId, + Device, + DeviceId, + Door, + DoorEmergencyStatus, + DoorGroup, + DoorGroupId, + DoorGroupTopology, + DoorId, + DoorLockingRule, + DoorLockingRuleType, + EnrollNfcCardResponse, + FetchAllHolidayGroupsResponse, + FetchAllSchedulesResponse, + FetchAllVisitorsExpansion, + FullUser, + HolidayGroup, + HolidayGroupId, + IdentityInvitationEmailFailure, + IdentityInvitationUser, + NamedResource, + NfcCard, + NfcCardEnrollmentSessionId, + NfcCardEnrollmentStatus, + NfcCardToken, + PartialHoliday, + Resource, + ResourceId, + Schedule, + ScheduleId, + SystemLogTopic, + TimePeriod, + User, + UserGroup, + UserGroupId, + UserId, + UserStatus, + Visitor, + VisitorId, + VisitorStatus, + VisitReason, + WeekSchedule, +) +from unifi_access.schemas._base import ( + ForbidExtraBaseModel, + IdentityResourceId, + UnixTimestampDateTime, +) +from unifi_access.schemas._identity import IdentityResource, IdentityResourceType +from unifi_access.schemas._system_log import FetchSystemLogsResponse + + +class ResponseCode(StrEnum): + @staticmethod + def _generate_next_value_( + name: str, start: int, count: int, last_values: list[str] + ) -> str: + return f"CODE_{name}" + + SUCCESS = "SUCCESS" + + PARAMS_INVALID = auto() + SYSTEM_ERROR = auto() + RESOURCE_NOT_FOUND = auto() + OPERATION_FORBIDDEN = auto() + AUTH_FAILED = auto() + ACCESS_TOKEN_INVALID = auto() + UNAUTHORIZED = auto() + NOT_EXISTS = auto() + USER_EMAIL_ERROR = auto() + USER_ACCOUNT_NOT_EXIST = auto() + USER_WORKER_NOT_EXISTS = auto() + ADMIN_EMAIL_EXIST = auto() # NOTE: undocumented + USER_NAME_DUPLICATED = auto() + USER_EMPLOYEE_NUMBER_EXIST = auto() # NOTE: undocumented + USER_CSV_IMPORT_INCOMPLETE_PROP = auto() + ACCESS_POLICY_USER_TIMEZONE_NOT_FOUND = auto() + ACCESS_POLICY_HOLIDAY_TIMEZONE_NOT_FOUND = auto() + ACCESS_POLICY_HOLIDAY_GROUP_NOT_FOUND = auto() + ACCESS_POLICY_HOLIDAY_NOT_FOUND = auto() + ACCESS_POLICY_SCHEDULE_NOT_FOUND = auto() + ACCESS_POLICY_HOLIDAY_NAME_EXIST = auto() + ACCESS_POLICY_HOLIDAY_GROUP_NAME_EXIST = auto() + ACCESS_POLICY_SCHEDULE_NAME_EXIST = auto() + ACCESS_POLICY_SCHEDULE_CAN_NOT_DELETE = auto() + ACCESS_POLICY_HOLIDAY_GROUP_CAN_NOT_DELETE = auto() + CREDS_NFC_HAS_BIND_USER = auto() + CREDS_DISABLE_TRANSFER_UID_USER_NFC = auto() + CREDS_NFC_READ_SESSION_NOT_FOUND = auto() + CREDS_NFC_READ_POLL_TOKEN_EMPTY = auto() + CREDS_NFC_CARD_IS_PROVISION = auto() + CREDS_NFC_CARD_PROVISION_FAILED = auto() + CREDS_NFC_CARD_INVALID = auto() + CREDS_NFC_CARD_CANNOT_BE_DELETE = auto() + CREDS_PIN_CREDS_ALREADY_EXIST = auto() + CREDS_PIN_CREDS_LENGTH_INVALID = auto() + SPACE_DEVICE_BOUND_LOCATION_NOT_FOUND = auto() + DEVICE_DEVICE_VERSION_NOT_FOUND = auto() + DEVICE_DEVICE_VERSION_TOO_OLD = auto() + DEVICE_DEVICE_BUSY = auto() + DEVICE_DEVICE_NOT_FOUND = auto() + DEVICE_DEVICE_OFFLINE = auto() + OTHERS_UID_ADOPTED_NOT_SUPPORTED = auto() + HOLIDAY_GROUP_CAN_NOT_DELETE = auto() + HOLIDAY_GROUP_CAN_NOT_EDIT = auto() + DEVICE_WEBHOOK_ENDPOINT_DUPLICATED = auto() + + +class UnifiAccessError(Exception): + """Exception version of `ErrorResponse`""" + + def __init__(self, code: ResponseCode, msg: str) -> None: + self.code = code + self.msg = msg + + super().__init__(code, msg) + + +class ErrorResponse(ForbidExtraBaseModel): + """A response with an error code, and no data""" + + code: Literal[ + ResponseCode.PARAMS_INVALID, + ResponseCode.SYSTEM_ERROR, + ResponseCode.RESOURCE_NOT_FOUND, + ResponseCode.OPERATION_FORBIDDEN, + ResponseCode.AUTH_FAILED, + ResponseCode.ACCESS_TOKEN_INVALID, + ResponseCode.UNAUTHORIZED, + ResponseCode.NOT_EXISTS, + ResponseCode.ADMIN_EMAIL_EXIST, + ResponseCode.USER_EMAIL_ERROR, + ResponseCode.USER_ACCOUNT_NOT_EXIST, + ResponseCode.USER_WORKER_NOT_EXISTS, + ResponseCode.USER_NAME_DUPLICATED, + ResponseCode.USER_EMPLOYEE_NUMBER_EXIST, + ResponseCode.USER_CSV_IMPORT_INCOMPLETE_PROP, + ResponseCode.ACCESS_POLICY_USER_TIMEZONE_NOT_FOUND, + ResponseCode.ACCESS_POLICY_HOLIDAY_TIMEZONE_NOT_FOUND, + ResponseCode.ACCESS_POLICY_HOLIDAY_GROUP_NOT_FOUND, + ResponseCode.ACCESS_POLICY_HOLIDAY_NOT_FOUND, + ResponseCode.ACCESS_POLICY_SCHEDULE_NOT_FOUND, + ResponseCode.ACCESS_POLICY_HOLIDAY_NAME_EXIST, + ResponseCode.ACCESS_POLICY_HOLIDAY_GROUP_NAME_EXIST, + ResponseCode.ACCESS_POLICY_SCHEDULE_NAME_EXIST, + ResponseCode.ACCESS_POLICY_SCHEDULE_CAN_NOT_DELETE, + ResponseCode.ACCESS_POLICY_HOLIDAY_GROUP_CAN_NOT_DELETE, + ResponseCode.CREDS_NFC_HAS_BIND_USER, + ResponseCode.CREDS_DISABLE_TRANSFER_UID_USER_NFC, + ResponseCode.CREDS_NFC_READ_SESSION_NOT_FOUND, + ResponseCode.CREDS_NFC_READ_POLL_TOKEN_EMPTY, + ResponseCode.CREDS_NFC_CARD_IS_PROVISION, + ResponseCode.CREDS_NFC_CARD_PROVISION_FAILED, + ResponseCode.CREDS_NFC_CARD_INVALID, + ResponseCode.CREDS_NFC_CARD_CANNOT_BE_DELETE, + ResponseCode.CREDS_PIN_CREDS_ALREADY_EXIST, + ResponseCode.CREDS_PIN_CREDS_LENGTH_INVALID, + ResponseCode.SPACE_DEVICE_BOUND_LOCATION_NOT_FOUND, + ResponseCode.DEVICE_DEVICE_VERSION_NOT_FOUND, + ResponseCode.DEVICE_DEVICE_VERSION_TOO_OLD, + ResponseCode.DEVICE_DEVICE_BUSY, + ResponseCode.DEVICE_DEVICE_NOT_FOUND, + ResponseCode.DEVICE_DEVICE_OFFLINE, + ResponseCode.OTHERS_UID_ADOPTED_NOT_SUPPORTED, + ResponseCode.HOLIDAY_GROUP_CAN_NOT_DELETE, + ResponseCode.HOLIDAY_GROUP_CAN_NOT_EDIT, + ResponseCode.DEVICE_WEBHOOK_ENDPOINT_DUPLICATED, + ] + msg: str + # sometimes the errors include this + data: Any | None = None + + def success_or_raise(self) -> Never: + raise UnifiAccessError(self.code, self.msg) + + +class ResponsePagination(ForbidExtraBaseModel): + page_num: int + page_size: int + total: int + + +# TODO: this has nicer syntax in Python 3.12, but not currently +# supported in Pydantic +ResponseDataType = TypeVar("ResponseDataType") + + +class SuccessResponse(ForbidExtraBaseModel, Generic[ResponseDataType]): + """A successful response containing data""" + + code: Literal[ResponseCode.SUCCESS] + msg: str + # sometimes the Access API omits this when it would be null + data: ResponseDataType = Field(default=None, validate_default=True) # type: ignore + pagination: ResponsePagination | None = None + + def success_or_raise(self) -> Self: + return self + + +# TODO: this is natively supported by the `type` keyword in 3.12 +Response = TypeAliasType( + "Response", + SuccessResponse[ResponseDataType] | ErrorResponse, + type_params=(ResponseDataType,), +) + + +class RequestPagination(ForbidExtraBaseModel): + page_num: int | None = None + page_size: int | None = None + + +# used for both User and Visitor +class AssignNfcCardRequest(ForbidExtraBaseModel): + token: NfcCardToken + """Token of the NFC card.""" + force_add: bool | None = None + """Determine whether to overwrite an NFC card that has already been assigned.""" + + +class AccessClient: + """Client for the UniFi Access API""" + + def __init__(self, host: str, api_token: str, verify: bool = True) -> None: + self._base_url = f"https://{host}/api/v1/developer" + + self._session = requests.Session() + self._session.headers.update( + { + "Authorization": f"Bearer {api_token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + ) + self._session.verify = verify + + def register_user( + self, + first_name: str, + last_name: str, + user_email: str | None = None, + employee_number: str | None = None, + onboard_time: UnixTimestampDateTime | None = None, + ) -> Response[User]: + """3.2 User Registration""" + + class RegisterUserRequest(ForbidExtraBaseModel): + first_name: str + last_name: str + user_email: str | None = None + employee_number: str | None = None + onboard_time: UnixTimestampDateTime | None = None + + body = RegisterUserRequest( + first_name=first_name, + last_name=last_name, + user_email=user_email, + employee_number=employee_number, + onboard_time=onboard_time, + ).model_dump(exclude_none=True) + + r = self._session.post(f"{self._base_url}/users", json=body) + # TODO: not the complete User + return TypeAdapter(Response[User]).validate_json(r.content) + + def update_user( # noqa: PLR0913 + self, + user_id: UserId, + first_name: str | None = None, + last_name: str | None = None, + user_email: str | None = None, + employee_number: str | None = None, + onboard_time: UnixTimestampDateTime | None = None, + status: UserStatus | None = None, + ) -> Response[None]: + """3.3 Update User""" + + class UpdateUserRequest(ForbidExtraBaseModel): + first_name: str | None = None + last_name: str | None = None + user_email: str | None = None + employee_number: str | None = None + onboard_time: UnixTimestampDateTime | None = None + status: UserStatus | None = None + + body = UpdateUserRequest( + first_name=first_name, + last_name=last_name, + user_email=user_email, + employee_number=employee_number, + onboard_time=onboard_time, + status=status, + ).model_dump(exclude_none=True) + + r = self._session.put(f"{self._base_url}/users/{user_id}", json=body) + # TODO: maybe not the complete User + return TypeAdapter(Response[None]).validate_json(r.content) + + def fetch_user( + self, user_id: UserId, expand_access_policies: bool = False + ) -> Response[FullUser]: + """3.4 Fetch User""" + params = {"expand[]": "access_policy"} if expand_access_policies else {} + + r = self._session.get(f"{self._base_url}/users/{user_id}", params=params) + return TypeAdapter(Response[FullUser]).validate_json(r.content) + + def fetch_all_users( + self, + expand_access_policies: bool = False, + page_num: int | None = None, + page_size: int | None = None, + ) -> Response[list[FullUser]]: + """3.5 Fetch All Users""" + + class FetchAllUsersParams(RequestPagination): + model_config = ConfigDict(populate_by_name=True) + expand: list[str] | None = Field(alias=str("expand[]"), default=None) # noqa: UP018 + + params = FetchAllUsersParams( + expand=["access_policy"] if expand_access_policies else None, + page_num=page_num, + page_size=page_size, + ).model_dump(exclude_none=True, by_alias=True) + + r = self._session.get(f"{self._base_url}/users", params=params) + return TypeAdapter(Response[list[FullUser]]).validate_json(r.content) + + def assign_access_policy_to_user( + self, user_id: UserId, access_policy_ids: list[AccessPolicyId] + ) -> Response[None]: + """3.6 Assign Access Policy to User""" + body = {"access_policy_ids": access_policy_ids} + + r = self._session.put( + f"{self._base_url}/users/{user_id}/access_policies", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def assign_nfc_card_to_user( + self, user_id: UserId, token: NfcCardToken, force_add: bool | None = None + ) -> Response[None]: + """3.7 Assign NFC Card to User""" + + body = AssignNfcCardRequest(token=token, force_add=force_add).model_dump( + exclude_none=True + ) + + r = self._session.put(f"{self._base_url}/users/{user_id}/nfc_cards", json=body) + return TypeAdapter(Response[None]).validate_json(r.content) + + def unassign_nfc_card_from_user( + self, user_id: UserId, token: NfcCardToken + ) -> Response[None]: + """3.8 Unassign NFC Card from User""" + body = {"token": token} + + r = self._session.put( + f"{self._base_url}/users/{user_id}/nfc_cards/delete", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def assign_pin_code_to_user(self, user_id: UserId, pin_code: str) -> Response[None]: + """3.9 Assign PIN Code to User""" + body = {"pin_code": pin_code} + + r = self._session.put(f"{self._base_url}/users/{user_id}/pin_codes", json=body) + return TypeAdapter(Response[None]).validate_json(r.content) + + def unassign_pin_code_from_user(self, user_id: UserId) -> Response[None]: + """3.10 Unassign PIN Code from User""" + + r = self._session.delete(f"{self._base_url}/users/{user_id}/pin_codes") + return TypeAdapter(Response[None]).validate_json(r.content) + + def create_user_group( + self, + name: str, + up_id: UserGroupId | None = None, + ) -> Response[UserGroup | None]: + """3.11 Create User Group + !!! note "The docs do not describe this as returning any data" + Returns None if the user group already existed + """ + + class CreateUserGroupRequest(ForbidExtraBaseModel): + name: str + up_id: UserGroupId | None = None + + body = CreateUserGroupRequest(name=name, up_id=up_id).model_dump( + exclude_none=True + ) + + r = self._session.post(f"{self._base_url}/user_groups", json=body) + return TypeAdapter(Response[UserGroup | None]).validate_json(r.content) + + def fetch_all_user_groups(self) -> Response[list[UserGroup]]: + """3.12 Fetch All User Groupes""" + + r = self._session.get(f"{self._base_url}/user_groups") + return TypeAdapter(Response[list[UserGroup]]).validate_json(r.content) + + def fetch_user_group( + self, + user_group_id: UserGroupId, + ) -> Response[UserGroup]: + """3.13 Fetch User Group""" + + r = self._session.get(f"{self._base_url}/user_groups/{user_group_id}") + return TypeAdapter(Response[UserGroup]).validate_json(r.content) + + def update_user_group( + self, + user_group_id: UserGroupId, + name: str, + up_id: UserGroupId | None = None, + ) -> Response[None]: + """3.14 Update User Group""" + + class UpdateUserGroupRequest(ForbidExtraBaseModel): + name: str + up_id: UserGroupId | None = None + + body = UpdateUserGroupRequest(name=name, up_id=up_id).model_dump( + exclude_none=True + ) + + r = self._session.put( + f"{self._base_url}/user_groups/{user_group_id}", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def delete_user_group( + self, + user_group_id: UserGroupId, + ) -> Response[None]: + """3.15 Delete User Group""" + + r = self._session.delete(f"{self._base_url}/user_groups/{user_group_id}") + return TypeAdapter(Response[None]).validate_json(r.content) + + def assign_user_to_user_group( + self, + user_group_id: UserGroupId, + users: list[UserId], + ) -> Response[None]: + """3.16 Assign User to User Group""" + + r = self._session.post( + f"{self._base_url}/user_groups/{user_group_id}/users", json=users + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def unassign_user_from_user_group( + self, + user_group_id: UserGroupId, + users: list[UserId], + ) -> Response[None]: + """3.17 Unassign User from User Group""" + + r = self._session.post( + f"{self._base_url}/user_groups/{user_group_id}/users/delete", json=users + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def fetch_users_in_a_user_group( + self, + user_group_id: UserGroupId, + ) -> Response[list[User]]: + """3.18 Fetch Users in a User Group""" + + r = self._session.get(f"{self._base_url}/user_groups/{user_group_id}/users") + return TypeAdapter(Response[list[User]]).validate_json(r.content) + + def fetch_all_users_in_a_user_group( + self, user_group_id: UserGroupId + ) -> Response[list[User]]: + """3.19 Fetch All Users in a User Group""" + + r = self._session.get(f"{self._base_url}/user_groups/{user_group_id}/users/all") + return TypeAdapter(Response[list[User]]).validate_json(r.content) + + def fetch_the_access_policies_assigned_to_a_user( + self, user_id: UserId, only_user_policies: bool | None = None + ) -> Response[list[AccessPolicy]]: + """3.20 Fetch the Access Policies Assigned to a User""" + + params = {"only_user_policies": "true" if only_user_policies else "false"} + r = self._session.get( + f"{self._base_url}/users/{user_id}/access_policies", params=params + ) + return TypeAdapter(Response[list[AccessPolicy]]).validate_json(r.content) + + def assign_access_policy_to_user_group( + self, user_group_id: UserGroupId, access_policy_ids: list[AccessPolicyId] + ) -> Response[None]: + """3.21 Assign Access Policy to User Group""" + + body = {"access_policy_ids": access_policy_ids} + + r = self._session.put( + f"{self._base_url}/user_groups/{user_group_id}/access_policies", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def fetch_the_access_policies_assigned_to_a_user_group( + self, + user_group_id: UserGroupId, + ) -> Response[list[AccessPolicy]]: + """3.22 Fetch the Access Policies Assigned to a User Group""" + + r = self._session.get( + f"{self._base_url}/user_groups/{user_group_id}/access_policies" + ) + return TypeAdapter(Response[list[AccessPolicy]]).validate_json(r.content) + + def create_visitor( # noqa: PLR0913 + self, + first_name: str, + last_name: str, + start_time: UnixTimestampDateTime, + end_time: UnixTimestampDateTime, + visit_reason: VisitReason, + remarks: str | None = None, + mobile_phone: str | None = None, + email: str | None = None, + visitor_company: str | None = None, + resources: Sequence[Resource] | None = None, + week_schedule: WeekSchedule | None = None, + ) -> Response[Visitor]: + """4.2 Create Visitor""" + + class CreateVisitorRequest(ForbidExtraBaseModel): + first_name: str + """First name of the visitor.""" + last_name: str + """Last name of the visitor.""" + remarks: str | None = None + """Remarks about the visitor.""" + mobile_phone: str | None = None + """Mobile phone number of the visitor.""" + email: str | None = None + """Email address of the visitor.""" + visitor_company: str | None = None + """Company name of the visitor.""" + start_time: UnixTimestampDateTime + """Start time of the visit.""" + end_time: UnixTimestampDateTime + """End time of the visit.""" + visit_reason: VisitReason + resources: Sequence[Resource] | None = None + """Assign available locations to the visitor.""" + week_schedule: WeekSchedule | None = None + """The customizable scheduling strategy for each day from + Sunday to Saturday. If not specified, it means access is + allowed every day.""" + + body = CreateVisitorRequest( + first_name=first_name, + last_name=last_name, + start_time=start_time, + end_time=end_time, + visit_reason=visit_reason, + remarks=remarks, + mobile_phone=mobile_phone, + email=email, + visitor_company=visitor_company, + resources=resources, + week_schedule=week_schedule, + ).model_dump(exclude_none=True) + + r = self._session.post(f"{self._base_url}/visitors", json=body) + return TypeAdapter(Response[Visitor]).validate_json(r.content) + + def fetch_visitor( + self, + visitor_id: VisitorId, + ) -> Response[Visitor]: + """4.3 Fetch Visitor""" + + r = self._session.get(f"{self._base_url}/visitors/{visitor_id}") + return TypeAdapter(Response[Visitor]).validate_json(r.content) + + def fetch_all_visitors( + self, + status: VisitorStatus | None = None, + keyword: str | None = None, + expand: list[FetchAllVisitorsExpansion] | None = None, + page_num: int | None = None, + page_size: int | None = None, + ) -> Response[list[Visitor]]: + """4.4 Fetch All Visitors""" + + class FetchAllVisitorsRequest(RequestPagination): + model_config = ConfigDict(populate_by_name=True) + + status: VisitorStatus | None = None + """The visitor's status. enum status""" + keyword: str | None = None + """Support prefix matching for first name and last + name. NOTE: The status filtering is disabled when + searching with keyword.""" + expand: list[FetchAllVisitorsExpansion] | None = Field( + alias=str("expand[]"), # noqa: UP018 + default=None, + ) + """Determine whether to return the objects (Optional). For + the "none" option, it means that no object will be + returned.""" + + params = FetchAllVisitorsRequest( + status=status, + keyword=keyword, + expand=expand, + page_num=page_num, + page_size=page_size, + ).model_dump(exclude_none=True, by_alias=True) + + r = self._session.get(f"{self._base_url}/visitors", params=params) + return TypeAdapter(Response[list[Visitor]]).validate_json(r.content) + + def update_visitor( # noqa: PLR0913 + self, + visitor_id: VisitorId, + first_name: str | None = None, + last_name: str | None = None, + start_time: UnixTimestampDateTime | None = None, + end_time: UnixTimestampDateTime | None = None, + visit_reason: VisitReason | None = None, + remarks: str | None = None, + mobile_phone: str | None = None, + email: str | None = None, + visitor_company: str | None = None, + resources: Sequence[Resource] | None = None, + week_schedule: WeekSchedule | None = None, + ) -> Response[Visitor]: + """4.5 Update Visitor""" + + class UpdateVisitorRequest(ForbidExtraBaseModel): + first_name: str | None = None + """First name of the visitor.""" + last_name: str | None = None + """Last name of the visitor.""" + remarks: str | None = None + """Remarks about the visitor.""" + mobile_phone: str | None = None + """Mobile phone number of the visitor.""" + email: str | None = None + """Email address of the visitor.""" + visitor_company: str | None = None + """Company name of the visitor.""" + start_time: UnixTimestampDateTime | None = None + """Start time of the visit.""" + end_time: UnixTimestampDateTime | None = None + """End time of the visit.""" + visit_reason: VisitReason | None = None + """Assign available locations to the visitor.""" + resources: Sequence[Resource] | None = None + """The customizable scheduling strategy for each day from + Sunday to Saturday. If not specified, it means access is + allowed every day.""" + week_schedule: WeekSchedule | None = None + + body = UpdateVisitorRequest( + first_name=first_name, + last_name=last_name, + start_time=start_time, + end_time=end_time, + visit_reason=visit_reason, + remarks=remarks, + mobile_phone=mobile_phone, + email=email, + visitor_company=visitor_company, + resources=resources, + week_schedule=week_schedule, + ).model_dump(exclude_none=True) + + r = self._session.put(f"{self._base_url}/visitors/{visitor_id}", json=body) + return TypeAdapter(Response[Visitor]).validate_json(r.content) + + def delete_visitor( + self, visitor_id: VisitorId, is_force: bool | None = None + ) -> Response[None]: + """4.6 Delete Visitor""" + + params = {"is_force": "true" if is_force else "false"} + r = self._session.delete( + f"{self._base_url}/visitors/{visitor_id}", params=params + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def assign_nfc_card_to_visitor( + self, visitor_id: VisitorId, token: NfcCardToken, force_add: bool | None = None + ) -> Response[None]: + """4.7 Assign NFC Card to Visitor""" + body = AssignNfcCardRequest(token=token, force_add=force_add).model_dump( + exclude_none=True + ) + + r = self._session.put( + f"{self._base_url}/visitors/{visitor_id}/nfc_cards", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def unassign_nfc_card_from_visitor( + self, visitor_id: VisitorId, token: NfcCardToken + ) -> Response[None]: + """4.8 Unassign NFC Card from Visitor""" + body = {"token": token} + + r = self._session.put( + f"{self._base_url}/visitors/{visitor_id}/nfc_cards/delete", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def assign_pin_code_to_visitor( + self, visitor_id: VisitorId, pin_code: str + ) -> Response[None]: + """4.9 Assign PIN Code to Visitor""" + body = {"pin_code": pin_code} + + r = self._session.put( + f"{self._base_url}/visitors/{visitor_id}/pin_codes", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def unassign_pin_code_from_visitor(self, visitor_id: VisitorId) -> Response[None]: + """4.10 Unassign PIN Code from Visitor""" + + r = self._session.delete(f"{self._base_url}/visitors/{visitor_id}/pin_codes") + return TypeAdapter(Response[None]).validate_json(r.content) + + def create_access_policy( + self, + name: str, + schedule_id: ScheduleId, + resources: Sequence[Resource] | None = None, + ) -> Response[AccessPolicy]: + """5.2 Create Access Policy""" + + class CreateAccessPolicyRequest(ForbidExtraBaseModel): + name: str + resource: Sequence[Resource] | None = None + schedule_id: ScheduleId + + body = CreateAccessPolicyRequest( + name=name, schedule_id=schedule_id, resource=resources + ).model_dump(exclude_none=True) + + r = self._session.post(f"{self._base_url}/access_policies", json=body) + return TypeAdapter(Response[AccessPolicy]).validate_json(r.content) + + def update_access_policy( + self, + access_policy_id: AccessPolicyId, + name: str | None = None, + resources: Sequence[Resource] | None = None, + schedule_id: ScheduleId | None = None, + ) -> Response[AccessPolicy]: + """5.3 Update Access Policy""" + + class UpdateAccessPolicyRequest(ForbidExtraBaseModel): + name: str | None = None + resource: Sequence[Resource] | None = None + schedule_id: ScheduleId | None = None + + body = UpdateAccessPolicyRequest( + name=name, schedule_id=schedule_id, resource=resources + ).model_dump(exclude_none=True) + + r = self._session.put( + f"{self._base_url}/access_policies/{access_policy_id}", json=body + ) + return TypeAdapter(Response[AccessPolicy]).validate_json(r.content) + + def delete_access_policy( + self, access_policy_id: AccessPolicyId + ) -> Response[Literal["success"]]: + """5.4 Delete Access Policy""" + + r = self._session.delete(f"{self._base_url}/access_policies/{access_policy_id}") + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def fetch_access_policy( + self, access_policy_id: AccessPolicyId + ) -> Response[AccessPolicy]: + """5.5 Fetch Access Policy""" + + r = self._session.get(f"{self._base_url}/access_policies/{access_policy_id}") + return TypeAdapter(Response[AccessPolicy]).validate_json(r.content) + + def fetch_all_access_policies(self) -> Response[list[AccessPolicy]]: + """5.6 Fetch All Access Policies""" + + r = self._session.get(f"{self._base_url}/access_policies") + return TypeAdapter(Response[list[AccessPolicy]]).validate_json(r.content) + + def create_holiday_group( + self, + name: str, + description: str | None = None, + holidays: list[PartialHoliday] | None = None, + ) -> Response[HolidayGroup]: + """5.8 Create Holiday Group""" + + class CreateHolidayGroupRequest(ForbidExtraBaseModel): + name: str + description: str | None = None + holidays: list[PartialHoliday] | None = None + + body = CreateHolidayGroupRequest( + name=name, description=description, holidays=holidays + ).model_dump(exclude_none=True) + + r = self._session.post( + f"{self._base_url}/access_policies/holiday_groups", json=body + ) + return TypeAdapter(Response[HolidayGroup]).validate_json(r.content) + + def update_holiday_group( + self, + holiday_group_id: HolidayGroupId, + # NOTE: the docs mark `name` as required + name: str | None = None, + description: str | None = None, + holidays: list[PartialHoliday] | None = None, + ) -> Response[HolidayGroup]: + """5.9 Update Holiday Group""" + + class UpdateHolidayGroupRequest(ForbidExtraBaseModel): + name: str | None = None + description: str | None = None + holidays: list[PartialHoliday] | None = None + + body = UpdateHolidayGroupRequest( + name=name, description=description, holidays=holidays + ).model_dump(exclude_none=True) + + r = self._session.put( + f"{self._base_url}/access_policies/holiday_groups/{holiday_group_id}", + json=body, + ) + return TypeAdapter(Response[HolidayGroup]).validate_json(r.content) + + def delete_holiday_group( + self, holiday_group_id: HolidayGroupId + ) -> Response[Literal["success"]]: + """5.10 Delete Holiday Group""" + + r = self._session.delete( + f"{self._base_url}/access_policies/holiday_groups/{holiday_group_id}" + ) + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def fetch_holiday_group( + self, holiday_group_id: HolidayGroupId + ) -> Response[HolidayGroup]: + """5.11 Fetch Holiday Group""" + + r = self._session.get( + f"{self._base_url}/access_policies/holiday_groups/{holiday_group_id}" + ) + return TypeAdapter(Response[HolidayGroup]).validate_json(r.content) + + def fetch_all_holiday_groups(self) -> Response[list[FetchAllHolidayGroupsResponse]]: + """5.12 Fetch All Holiday Groups""" + + r = self._session.get(f"{self._base_url}/access_policies/holiday_groups") + return TypeAdapter(Response[list[FetchAllHolidayGroupsResponse]]).validate_json( + r.content + ) + + def create_schedule( + self, + name: str, + # XXX: figure out what if "not specified" means + week_schedule: WeekSchedule, + holiday_group_id: HolidayGroupId | None = None, + holiday_schedule: list[TimePeriod] | None = None, + ) -> Response[Schedule]: + """5.14 Create Schedule""" + + class CreateScheduleRequest(ForbidExtraBaseModel): + name: str + week_schedule: WeekSchedule + holiday_group_id: HolidayGroupId | None = None + holiday_schedule: list[TimePeriod] | None = None + + body = CreateScheduleRequest( + name=name, + week_schedule=week_schedule, + holiday_group_id=holiday_group_id, + holiday_schedule=holiday_schedule, + ).model_dump(exclude_none=True) + + r = self._session.post(f"{self._base_url}/access_policies/schedules", json=body) + return TypeAdapter(Response[Schedule]).validate_json(r.content) + + def update_schedule( + self, + schedule_id: ScheduleId, + name: str | None = None, + # XXX: figure out what "if not specified" means. Do I need to + # differentiate between None and missing here? + week_schedule: WeekSchedule | None = None, + holiday_group_id: HolidayGroupId | None = None, + holiday_schedule: list[TimePeriod] | None = None, + ) -> Response[dict[Any, Any]]: # TODO: this should really return dict[Never, Never] + """5.15 Update Schedule""" + + class UpdateScheduleRequest(ForbidExtraBaseModel): + name: str | None = None + week_schedule: WeekSchedule | None = None + holiday_group_id: HolidayGroupId | None = None + holiday_schedule: list[TimePeriod] | None = None + + body = UpdateScheduleRequest( + name=name, + week_schedule=week_schedule, + holiday_group_id=holiday_group_id, + holiday_schedule=holiday_schedule, + ).model_dump(exclude_none=True) + + r = self._session.put( + f"{self._base_url}/access_policies/schedules/{schedule_id}", json=body + ) + return TypeAdapter(Response[dict[Any, Any]]).validate_json(r.content) + + # NOTE: the API docs incorrectly define the response as a subset of `Schedule` + def fetch_schedule(self, schedule_id: ScheduleId) -> Response[Schedule]: + """5.16 Fetch Schedule""" + + r = self._session.get( + f"{self._base_url}/access_policies/schedules/{schedule_id}" + ) + return TypeAdapter(Response[Schedule]).validate_json(r.content) + + def fetch_all_schedules(self) -> Response[list[FetchAllSchedulesResponse]]: + """5.17 Fetch All Schedules""" + + r = self._session.get(f"{self._base_url}/access_policies/schedules") + return TypeAdapter(Response[list[FetchAllSchedulesResponse]]).validate_json( + r.content + ) + + def delete_schedule(self, schedule_id: ScheduleId) -> Response[Literal["success"]]: + """5.18 Delete Schedule""" + + r = self._session.delete( + f"{self._base_url}/access_policies/schedules/{schedule_id}" + ) + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def generate_pin_code(self) -> Response[str]: + r = self._session.post(f"{self._base_url}/credentials/pin_codes") + return TypeAdapter(Response["str"]).validate_json(r.content) + + def begin_enroll_card( + self, device_id: DeviceId, reset_ua_card: bool | None = None + ) -> Response[EnrollNfcCardResponse]: + """6.2 Enroll NFC Card""" + + class EnrollNFCCardRequest(ForbidExtraBaseModel): + device_id: DeviceId + reset_ua_card: bool | None = None + + body = EnrollNFCCardRequest( + device_id=device_id, reset_ua_card=reset_ua_card + ).model_dump(exclude_none=True) + + r = self._session.post( + f"{self._base_url}/credentials/nfc_cards/sessions", json=body + ) + return TypeAdapter(Response[EnrollNfcCardResponse]).validate_json(r.content) + + def fetch_enroll_card_status( + self, + session_id: NfcCardEnrollmentSessionId, + ) -> Response[NfcCardEnrollmentStatus]: + """6.3 Fetch NFC Card Enrollment Status""" + r = self._session.get( + f"{self._base_url}/credentials/nfc_cards/sessions/{session_id}" + ) + return TypeAdapter(Response[NfcCardEnrollmentStatus]).validate_json(r.content) + + def remove_enrollment_session( + self, + session_id: NfcCardEnrollmentSessionId, + ) -> Response[Literal["success"]]: + """6.4 Remove a Session Created for NFC Card Enrollment""" + r = self._session.delete( + f"{self._base_url}/credentials/nfc_cards/sessions/{session_id}" + ) + return TypeAdapter(Response[str]).validate_json(r.content) + + def fetch_nfc_card(self, nfc_card_token: NfcCardToken) -> Response[NfcCard]: + """6.7 Fetch NFC Card""" + r = self._session.get( + f"{self._base_url}/credentials/nfc_cards/tokens/{nfc_card_token}" + ) + return TypeAdapter(Response[NfcCard]).validate_json(r.content) + + def fetch_all_nfc_cards( + self, page_num: int | None = None, page_size: int | None = None + ) -> Response[list[NfcCard]]: + """6.8 Fetch NFC Cards""" + params = RequestPagination(page_num=page_num, page_size=page_size) + r = self._session.get( + f"{self._base_url}/credentials/nfc_cards/tokens", params=params + ) + return TypeAdapter(Response[list[NfcCard]]).validate_json(r.content) + + def delete_nfc_card( + self, nfc_card_token: NfcCardToken + ) -> Response[Literal["success"]]: + """6.7 Fetch NFC Card""" + r = self._session.delete( + f"{self._base_url}/credentials/nfc_cards/tokens/{nfc_card_token}" + ) + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def fetch_door_group_topology(self) -> Response[DoorGroupTopology]: + """7.1 Fetch Door Group Topology""" + r = self._session.get(f"{self._base_url}/door_groups/topology") + return TypeAdapter(Response[list[DoorGroupTopology]]).validate_json(r.content) + + def create_door_group( + self, group_name: str, resources: list[ResourceId] + ) -> Response[DoorGroup[Resource]]: + """7.2 Create Door Group""" + body = {"group_name": group_name, "resources": resources} + + r = self._session.post(f"{self._base_url}/door_groups", json=body) + return TypeAdapter(Response[DoorGroup[Resource]]).validate_json(r.content) + + def fetch_door_group( + self, door_group_id: DoorGroupId + ) -> Response[DoorGroup[NamedResource]]: + """7.3 Fetch Door Group""" + r = self._session.get(f"{self._base_url}/door_groups/{door_group_id}") + return TypeAdapter(Response[DoorGroup[NamedResource]]).validate_json(r.content) + + def update_door_group( + self, + door_group_id: DoorGroupId, + group_name: str | None = None, + resources: list[ResourceId] | None = None, + ) -> Response[DoorGroup[Resource]]: + """7.4 Update Door Group""" + + class UpdateDoorGroupRequest(ForbidExtraBaseModel): + group_name: str | None = None + resources: list[ResourceId] | None = None + + body = UpdateDoorGroupRequest( + group_name=group_name, resources=resources + ).model_dump(exclude_none=True) + + r = self._session.put( + f"{self._base_url}/door_groups/{door_group_id}", json=body + ) + return TypeAdapter(Response[DoorGroup[Resource]]).validate_json(r.content) + + def fetch_all_door_groups(self) -> Response[list[DoorGroup[Resource]]]: + """7.5 Fetch All Door Groups""" + r = self._session.get(f"{self._base_url}/door_groups") + return TypeAdapter(Response[list[DoorGroup[Resource]]]).validate_json(r.content) + + def delete_door_group( + self, door_group_id: DoorGroupId + ) -> Response[Literal["success"]]: + """7.6 Delete Door Group""" + r = self._session.get(f"{self._base_url}/door_groups/{door_group_id}") + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def fetch_door(self, door_id: DoorId) -> Response[Door]: + """7.7 Fetch Door""" + r = self._session.get(f"{self._base_url}/doors/{door_id}") + return TypeAdapter(Response[Door]).validate_json(r.content) + + def fetch_all_doors(self) -> Response[list[Door]]: + """7.8 Fetch All Doors""" + r = self._session.get(f"{self._base_url}/doors") + return TypeAdapter(Response[list[Door]]).validate_json(r.content) + + def unlock_door(self, door_id: DoorId) -> Response[Literal["success"]]: + """7.9 Remote Door Unlocking""" + r = self._session.put(f"{self._base_url}/doors/{door_id}/unlock") + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + # XXX: could do a better job of typing for interval + def set_temporary_door_locking_rule( + self, door_id: DoorId, type_: DoorLockingRuleType, interval: int | None = None + ) -> Response[Literal["success"]]: + """7.10 Set Temporary Door Locking Rule""" + + class SetTemporaryDoorLockingRuleRequest(ForbidExtraBaseModel): + type: DoorLockingRuleType + interval: int | None = None + + body = SetTemporaryDoorLockingRuleRequest( + type=type_, interval=interval + ).model_dump(exclude_none=True) + r = self._session.put(f"{self._base_url}/doors/{door_id}/lock_rule", json=body) + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def fetch_door_locking_rule(self, door_id: DoorId) -> Response[DoorLockingRule]: + """7.11 Fetch Door Locking Rule""" + r = self._session.get(f"{self._base_url}/doors/{door_id}/lock_rule") + return TypeAdapter(Response[DoorLockingRule]).validate_json(r.content) + + # TODO: why is this two bools instead of an enum? Could probably be improved. + def set_door_emergency_status( + self, + lockdown: bool = False, + evacuation: bool = False, + ) -> Response[Literal["success"]]: + """7.12 Set Door Emergency Status""" + + body = DoorEmergencyStatus(lockdown=lockdown, evacuation=evacuation).model_dump( + exclude_none=True + ) + r = self._session.put(f"{self._base_url}/doors/settings/emergency", json=body) + return TypeAdapter(Response[Literal["success"]]).validate_json(r.content) + + def fetch_door_emergency_status(self) -> Response[DoorEmergencyStatus]: + """7.13 Fetch Door Emergency Status""" + r = self._session.get(f"{self._base_url}/doors/settings/emergency") + return TypeAdapter(Response[DoorEmergencyStatus]).validate_json(r.content) + + def fetch_devices(self) -> Response[list[Device]]: + """8.1 Fetch Devices""" + r = self._session.get(f"{self._base_url}/devices") + return TypeAdapter(Response[list[Device]]).validate_json(r.content) + + def fetch_system_logs( # noqa: PLR0913 + self, + topic: SystemLogTopic, + since: datetime.datetime | None = None, + until: datetime.datetime | None = None, + actor_id: ActorId | None = None, + page_num: int | None = None, + page_size: int | None = None, + ) -> Response[FetchSystemLogsResponse]: + """9.2 Fetch System Logs""" + params = RequestPagination(page_num=page_num, page_size=page_size).model_dump( + exclude_none=True + ) + + class FetchSystemLogsRequest(ForbidExtraBaseModel): + topic: SystemLogTopic + since: UnixTimestampDateTime | None = None + until: UnixTimestampDateTime | None = None + actor_id: ActorId | None = None + + body = FetchSystemLogsRequest( + topic=topic, + since=since, + until=until, + actor_id=actor_id, + ).model_dump(exclude_none=True) + + r = self._session.post( + f"{self._base_url}/system/logs", params=params, json=body + ) + return TypeAdapter(Response[FetchSystemLogsResponse]).validate_json(r.content) + + def export_system_logs( + self, + topic: SystemLogTopic, + since: datetime.datetime, + until: datetime.datetime, + timezone: str, + actor_id: ActorId | None = None, + ) -> bytes: + """9.3 Export System Logs""" + + class ExportSystemLogsRequest(ForbidExtraBaseModel): + topic: SystemLogTopic + since: UnixTimestampDateTime + until: UnixTimestampDateTime + timezone: str + actor_id: ActorId | None = None + + body = ExportSystemLogsRequest( + topic=topic, + since=since, + until=until, + timezone=timezone, + actor_id=actor_id, + ).model_dump(exclude_none=True) + + r = self._session.post(f"{self._base_url}/system/logs/export", json=body) + return r.content + + def send_unifi_identity_invitations( + self, users: list[IdentityInvitationUser] + ) -> Response[list[IdentityInvitationEmailFailure]]: + """10.1 Send UniFi Identity Invitations""" + body = TypeAdapter(list[IdentityInvitationUser]).dump_python( + users, exclude_none=True + ) + + r = self._session.post( + f"{self._base_url}/users/identity/invitations", json=body + ) + return TypeAdapter( + Response[list[IdentityInvitationEmailFailure]] + ).validate_json(r.content) + + def fetch_available_resources( + self, resource_type: Iterable[IdentityResourceType] | None = None + ) -> Response[dict[IdentityResourceType, list[IdentityResource]]]: + """10.2 Fetch Available Resources""" + + params = {"resource_type": ",".join(resource_type)} if resource_type else None + + r = self._session.get( + f"{self._base_url}/users/identity/assignments", params=params + ) + return TypeAdapter( + Response[dict[IdentityResourceType, list[IdentityResource]]] + ).validate_json(r.content) + + def assign_resources_to_user( + self, + user_id: UserId, + resource_type: IdentityResourceType, + resource_ids: list[IdentityResourceId], + ) -> Response[None]: + """10.3 Assign Resources to Users""" + body = {"resource_type": resource_type, "resource_ids": resource_ids} + + r = self._session.post( + f"{self._base_url}/users/{user_id}/identity/assignments", json=body + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def fetch_resources_assigned_to_user( + self, + user_id: UserId, + ) -> Response[dict[IdentityResourceType, list[IdentityResource]]]: + """10.4 Fetch Resources Assigned to Users""" + + r = self._session.get(f"{self._base_url}/users/{user_id}/identity/assignments") + return TypeAdapter( + Response[dict[IdentityResourceType, list[IdentityResource]]] + ).validate_json(r.content) + + def assign_resources_to_user_group( + self, + user_group_id: UserGroupId, + resource_type: IdentityResourceType, + resource_ids: list[IdentityResourceId], + ) -> Response[None]: + """10.5 Assign Resources to User Groups""" + body = {"resource_type": resource_type, "resource_ids": resource_ids} + + r = self._session.post( + f"{self._base_url}/user_groups/{user_group_id}/identity/assignments", + json=body, + ) + return TypeAdapter(Response[None]).validate_json(r.content) + + def fetch_resources_assigned_to_user_group( + self, + user_group_id: UserGroupId, + ) -> Response[dict[IdentityResourceType, list[IdentityResource]]]: + """10.6 Fetch Resources Assigned to User Groups""" + + r = self._session.get( + f"{self._base_url}/user_groups/{user_group_id}/identity/assignments" + ) + return TypeAdapter( + Response[dict[IdentityResourceType, list[IdentityResource]]] + ).validate_json(r.content) diff --git a/src/unifi_access/py.typed b/src/unifi_access/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/unifi_access/schemas/__init__.py b/src/unifi_access/schemas/__init__.py new file mode 100644 index 0000000..900a5f4 --- /dev/null +++ b/src/unifi_access/schemas/__init__.py @@ -0,0 +1,145 @@ +from ._access_policy import ( + AccessPolicy, + FetchAllHolidayGroupsResponse, + FetchAllSchedulesResponse, + Holiday, + HolidayGroup, + PartialHoliday, + PartialSchedule, + Schedule, + TimePeriod, + WeekSchedule, +) +from ._base import ( + AccessPolicyId, + ActorId, + DeviceId, + DoorGroupId, + DoorGroupResource, + DoorId, + DoorResource, + FloorId, + HolidayGroupId, + HolidayId, + NamedDoorGroupResource, + NamedDoorGroupResourceWithIsBindHub, + NamedDoorResource, + NamedDoorResourceWithIsBindHub, + NamedResource, + NamedResourceWithIsBindHub, + NfcCardEnrollmentSessionId, + NfcCardId, + NfcCardToken, + PinCode, + Resource, + ResourceId, + ScheduleId, + UserGroupId, + UserId, + VisitorId, +) +from ._credential import ( + EnrollNfcCardResponse, + NfcCard, + NfcCardEnrollmentStatus, + NfcCardUser, +) +from ._device import Device +from ._identity import ( + IdentityInvitationEmailFailure, + IdentityInvitationUser, + IdentityResourceType, +) +from ._space import ( + Door, + DoorEmergencyStatus, + DoorGroup, + DoorGroupTopology, + DoorGroupType, + DoorLockingRule, + DoorLockingRuleType, + ResourceTopology, +) +from ._system_log import ( + SystemLogActor, + SystemLogAuthentication, + SystemLogEntry, + SystemLogEvent, + SystemLogTopic, +) +from ._user import FullUser, User, UserGroup, UserNfcCard, UserStatus +from ._visitor import ( + FetchAllVisitorsExpansion, + Visitor, + VisitorStatus, + VisitReason, +) + +__all__ = [ + "AccessPolicy", + "FetchAllHolidayGroupsResponse", + "FetchAllSchedulesResponse", + "Holiday", + "HolidayGroup", + "PartialHoliday", + "Schedule", + "PartialSchedule", + "TimePeriod", + "WeekSchedule", + "AccessPolicyId", + "DoorId", + "FloorId", + "DoorGroupId", + "ResourceId", + "ScheduleId", + "UserId", + "UserGroupId", + "VisitorId", + "NfcCardId", + "HolidayId", + "HolidayGroupId", + "DeviceId", + "NfcCardEnrollmentSessionId", + "NfcCardToken", + "ActorId", + "Resource", + "NamedResource", + "NamedResourceWithIsBindHub", + "DoorGroupResource", + "DoorResource", + "NamedDoorGroupResource", + "NamedDoorGroupResourceWithIsBindHub", + "NamedDoorResource", + "NamedDoorResourceWithIsBindHub", + "PinCode", + "EnrollNfcCardResponse", + "NfcCard", + "NfcCardEnrollmentStatus", + "NfcCardUser", + "Device", + "IdentityInvitationEmailFailure", + "IdentityInvitationUser", + "IdentityResourceType", + "Door", + "DoorEmergencyStatus", + "DoorGroup", + "DoorGroupTopology", + "DoorGroupType", + "DoorLockingRule", + "DoorLockingRuleType", + "ResourceTopology", + "SystemLogActor", + "SystemLogAuthentication", + "SystemLogEntry", + "SystemLogEvent", + "SystemLogTopic", + "FetchAllVisitorsExpansion", + "User", + "FullUser", + "UserGroup", + "UserNfcCard", + "UserStatus", + "Visitor", + "VisitorStatus", + "VisitReason", +] diff --git a/src/unifi_access/schemas/_access_policy.py b/src/unifi_access/schemas/_access_policy.py new file mode 100644 index 0000000..97199df --- /dev/null +++ b/src/unifi_access/schemas/_access_policy.py @@ -0,0 +1,151 @@ +from ._base import ( + AccessPolicyId, + ForbidExtraBaseModel, + FormattedTime, + HolidayGroupId, + HolidayId, + Resource, + RFC3339Datetime, + ScheduleId, +) + + +class AccessPolicy(ForbidExtraBaseModel): + """5.1 Access Policy Schemas""" + + id: AccessPolicyId + """Identity ID of the access policy.""" + name: str + """Name of the access policy.""" + resources: list[Resource] + """Specify the locations that can be accessed.""" + schedule_id: ScheduleId + """Identity ID of the schedule.""" + + +class Holiday(ForbidExtraBaseModel): + description: str | None = None + """Description of the holiday.""" + id: HolidayId + """Identity ID of the holiday.""" + name: str + """Name of the holiday.""" + repeat: bool + """Indicate whether the holiday repeats annually.""" + start_time: RFC3339Datetime + """Start time of the holiday""" + end_time: RFC3339Datetime + """End time of the holiday""" + is_template: bool | None = None + """!!! note "not listed in API docs" """ + + +class PartialHoliday(ForbidExtraBaseModel): + description: str | None = None + """Description of the holiday.""" + id: HolidayId | None = None + """Identity ID of the holiday.""" + name: str | None = None + """Name of the holiday.""" + repeat: bool | None = None + """Indicate whether the holiday repeats annually.""" + start_time: RFC3339Datetime | None = None + """Start time of the holiday""" + end_time: RFC3339Datetime | None = None + """End time of the holiday""" + + +class HolidayGroup(ForbidExtraBaseModel): + id: HolidayGroupId + """Identity ID of the holiday group.""" + name: str + """Name of the holiday group.""" + is_default: bool + """Indicate whether the holiday group is the system default.""" + description: str + """Description of the holiday group.""" + holidays: list[Holiday] | None = None + """A list of the holidays within the holiday group.""" + template_name: str | None = None + """!!! note "not listed in API docs" """ + is_private: bool | None = None + """!!! note "not listed in API docs" """ + + +class FetchAllHolidayGroupsResponse(ForbidExtraBaseModel): + id: HolidayGroupId + """Identity ID of the holiday group.""" + name: str + """Name of the holiday group.""" + is_default: bool + """ + Indicate whether the holiday group is the system default. + !!! note "not listed in API docs for this call" + """ + description: str + """Description of the holiday group.""" + count: int + """Total number of holidays""" + is_private: bool | None = None + """!!! note "not listed in API docs" """ + + +class TimePeriod(ForbidExtraBaseModel): + start_time: FormattedTime + """Start time of the access time period.""" + end_time: FormattedTime + """End time of the access time period.""" + + +class WeekSchedule(ForbidExtraBaseModel): + sunday: list[TimePeriod] + monday: list[TimePeriod] + tuesday: list[TimePeriod] + wednesday: list[TimePeriod] + thursday: list[TimePeriod] + friday: list[TimePeriod] + saturday: list[TimePeriod] + + +class _ScheduleBase(ForbidExtraBaseModel): + id: ScheduleId + """Identity ID of the schedule.""" + name: str + """Name of the schedule.""" + is_default: bool + """Indicate whether the schedule is the system default.""" + type: str + """Contains the access type, which is assigned to a user along with an access policy.""" + + +class Schedule(_ScheduleBase): + weekly: WeekSchedule + """ + The customizable scheduling strategy for each day from Sunday to Saturday. + If not specified, it means access is allowed every day. + """ + holiday_schedule: list[TimePeriod] + """Specify the accessible period during holidays. UniFi Access Requirement: 1.20.11 or later""" + holiday_group_id: HolidayGroupId + """Identity ID of the holiday group.""" + holiday_group: HolidayGroup + + +class PartialSchedule(_ScheduleBase): + weekly: WeekSchedule | None = None + """ + The customizable scheduling strategy for each day from Sunday to Saturday. + If not specified, it means access is allowed every day. + """ + holiday_schedule: list[TimePeriod] | None = None + """Specify the accessible period during holidays. UniFi Access Requirement: 1.20.11 or later""" + holiday_group_id: HolidayGroupId | None = None + """Identity ID of the holiday group.""" + holiday_group: HolidayGroup | None = None + + +class FetchAllSchedulesResponse(_ScheduleBase): + status: int + """!!! note "not listed in API docs" """ + holiday_group_id: HolidayGroupId + """!!! note "not listed in API docs" """ diff --git a/src/unifi_access/schemas/_base.py b/src/unifi_access/schemas/_base.py new file mode 100644 index 0000000..7d84909 --- /dev/null +++ b/src/unifi_access/schemas/_base.py @@ -0,0 +1,87 @@ +import datetime +from typing import Annotated, Literal, NewType + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + PlainSerializer, +) + + +class ForbidExtraBaseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +# TODO: these are mostly UUIDs +AccessPolicyId = NewType("AccessPolicyId", str) +DoorId = NewType("DoorId", str) +FloorId = NewType("FloorId", str) +DoorGroupId = NewType("DoorGroupId", str) +ResourceId = DoorId | DoorGroupId +ScheduleId = NewType("ScheduleId", str) +UserId = NewType("UserId", str) +UserGroupId = NewType("UserGroupId", str) +VisitorId = NewType("VisitorId", str) +NfcCardId = NewType("NfcCardId", str) +HolidayId = NewType("HolidayId", str) +HolidayGroupId = NewType("HolidayGroupId", str) +DeviceId = NewType("DeviceId", str) +NfcCardEnrollmentSessionId = NewType("NfcCardEnrollmentSessionId", str) +NfcCardToken = NewType("NfcCardToken", str) +IdentityResourceId = NewType("IdentityResourceId", str) + +ActorId = UserId | VisitorId | DeviceId + +UnixTimestampDateTime = Annotated[ + AwareDatetime, PlainSerializer(lambda dt: int(dt.timestamp()), return_type=int) +] +RFC3339Datetime = Annotated[ + AwareDatetime, + PlainSerializer( + lambda dt: dt.astimezone(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + return_type=str, + ), +] +FormattedTime = Annotated[ + datetime.time, + PlainSerializer(lambda t: t.isoformat(timespec="seconds"), return_type=str), +] + + +class DoorResource(ForbidExtraBaseModel): + type: Literal["door"] = "door" + id: DoorId + + +class NamedDoorResource(DoorResource): + name: str + + +class NamedDoorResourceWithIsBindHub(NamedDoorResource): + is_bind_hub: bool + + +class DoorGroupResource(ForbidExtraBaseModel): + type: Literal["door_group"] = "door_group" + id: DoorGroupId + + +class NamedDoorGroupResource(DoorGroupResource): + name: str + + +class NamedDoorGroupResourceWithIsBindHub(NamedDoorGroupResource): + is_bind_hub: bool + + +Resource = DoorResource | DoorGroupResource +NamedResource = NamedDoorResource | NamedDoorGroupResource +NamedResourceWithIsBindHub = ( + NamedDoorResourceWithIsBindHub | NamedDoorGroupResourceWithIsBindHub +) + + +class PinCode(ForbidExtraBaseModel): + token: str + """The user's PIN hash code credential for unlocking a door.""" diff --git a/src/unifi_access/schemas/_credential.py b/src/unifi_access/schemas/_credential.py new file mode 100644 index 0000000..d3cc0b5 --- /dev/null +++ b/src/unifi_access/schemas/_credential.py @@ -0,0 +1,58 @@ +from typing import Literal + +from ._base import ( + ForbidExtraBaseModel, + NfcCardEnrollmentSessionId, + NfcCardId, + NfcCardToken, + UserId, +) + + +# TODO: this should be a partial of User +class NfcCardUser(ForbidExtraBaseModel): + id: UserId + """Identity ID of the user.""" + first_name: str + """First name of the user.""" + last_name: str + """Last name of the user.""" + name: str + """Full name of the user.""" + avatar: str + """!!! note "not listed in API docs" """ + + +class NfcCard(ForbidExtraBaseModel): + token: NfcCardToken + """Identity token of the NFC card.""" + display_id: NfcCardId + """Display ID of the NFC card.""" + status: Literal["assigned", "pending", "disable", "deleted", "loss"] + """Status of the NFC card.""" + alias: str + """Preferred name of the NFC card.""" + card_type: str + """Type of the NFC card.""" + user_id: Literal[""] | UserId + """Owner ID of the NFC card.""" + user_type: Literal["USER", "VISITOR", ""] + """Type of the owner.""" + user: NfcCardUser + """Owner of the NFC card.""" + note: str + """!!! note "not listed in API docs" """ + + +class EnrollNfcCardResponse(ForbidExtraBaseModel): + session_id: NfcCardEnrollmentSessionId + """The session for enrolling an NFC card.""" + + +class NfcCardEnrollmentStatus(ForbidExtraBaseModel): + token: NfcCardToken + """Unique NFC card token.""" + id: NfcCardId + """Display ID of the NFC card. + !!! note "documented as `card_id`" + """ diff --git a/src/unifi_access/schemas/_device.py b/src/unifi_access/schemas/_device.py new file mode 100644 index 0000000..7c5d55b --- /dev/null +++ b/src/unifi_access/schemas/_device.py @@ -0,0 +1,12 @@ +from ._base import DeviceId, ForbidExtraBaseModel + + +class Device(ForbidExtraBaseModel): + id: DeviceId + """Identity ID of the device.""" + name: str + """Name of the device.""" + type: str + """Type of the device.""" + full_name: str + """Full name of the device.""" diff --git a/src/unifi_access/schemas/_identity.py b/src/unifi_access/schemas/_identity.py new file mode 100644 index 0000000..9372144 --- /dev/null +++ b/src/unifi_access/schemas/_identity.py @@ -0,0 +1,36 @@ +from enum import StrEnum +from typing import Any + +from ._base import ForbidExtraBaseModel, IdentityResourceId, UserId + + +class IdentityInvitationUser(ForbidExtraBaseModel): + user_id: UserId + email: str | None = None + + +class IdentityInvitationEmailFailure(ForbidExtraBaseModel): + error_code: str + error_msg: str + user_email: str + user_id: UserId + + +class IdentityResourceType(StrEnum): + EV_STATION = "ev_station" + VPN = "vpn" + WIFI = "wifi" + + +class IdentityResource(ForbidExtraBaseModel): + id: IdentityResourceId + """Identity ID of the resources.""" + name: str + """Name of the resources.""" + deleted: bool + """Indicate whether the resource is disabled.""" + short_name: str + """!!! note "not listed in API docs" """ + # XXX: narrower type + metadata: Any | None + """!!! note "not listed in API docs" """ diff --git a/src/unifi_access/schemas/_space.py b/src/unifi_access/schemas/_space.py new file mode 100644 index 0000000..8975d48 --- /dev/null +++ b/src/unifi_access/schemas/_space.py @@ -0,0 +1,104 @@ +from enum import StrEnum +from typing import Generic, Literal, TypeVar + +from ._base import ( + DoorGroupId, + DoorId, + FloorId, + ForbidExtraBaseModel, + NamedResourceWithIsBindHub, + Resource, + UnixTimestampDateTime, +) + + +class DoorGroupType(StrEnum): + ACCESS = "access" + BUILDING = "building" + + +class ResourceTopology(ForbidExtraBaseModel): + id: str + """Identity ID of the floor.""" + type: str + """Type of the floor.""" + name: str + """Name of the floor.""" + resources: list[NamedResourceWithIsBindHub] + """Contains all the doors on the floor.""" + is_bind_hub: bool | None = None + """ + Indicate whether the door has bound to a hub device. + It can only # be used for remote opening if it's bound. + """ + + +class DoorGroupTopology(ForbidExtraBaseModel): + # XXX: is this really optional? + id: str | None = None + """Identity ID of the door group.""" + type: DoorGroupType + """ + The building type contains all the doors; the access type represents all the customized door groups. + """ + name: str + """!!! note "not listed in API docs" """ + resource_topologies: list[ResourceTopology] + """Contains information about the floor and all its associated doors.""" + + +ResourceType = TypeVar("ResourceType", bound=Resource) + + +class DoorGroup(ForbidExtraBaseModel, Generic[ResourceType]): + id: DoorGroupId + """Identity ID of the door group.""" + name: str + """Name of the door group.""" + type: DoorGroupType + """!!! note "not listed in API docs" """ + resources: list[ResourceType] + + +class Door(ForbidExtraBaseModel): + id: DoorId + """Identity ID of the door.""" + name: str + """Name of the door.""" + full_name: str + """Full name of the door.""" + floor_id: FloorId + """Identity ID of the floor.""" + # XXX: is this always 'door'? + type: Literal["door"] = "door" + """Type of the door.""" + is_bind_hub: bool + """ + Indicate whether the door has bound to a hub device. + It can only be used for remote opening if it's bound. + !!! note "api doc describes this as a string" + """ + door_lock_relay_status: Literal["lock", "unlock"] + """Door lock status.""" + # TODO: convert empty string to None? + door_position_status: Literal["open", "close", ""] + """A null value means that no device is connected""" + + +class DoorLockingRuleType(StrEnum): + KEEP_LOCK = "keep_lock" + KEEP_UNLOCK = "keep_unlock" + CUSTOM = "custom" + RESET = "reset" + LOCK_EARLY = "lock_early" + + +class DoorLockingRule(ForbidExtraBaseModel): + type: DoorLockingRuleType + ended_time: UnixTimestampDateTime + + +class DoorEmergencyStatus(ForbidExtraBaseModel): + # NOTE: the api docs list these as not required, but always include them + lockdown: bool = False + evacuation: bool = False diff --git a/src/unifi_access/schemas/_system_log.py b/src/unifi_access/schemas/_system_log.py new file mode 100644 index 0000000..11b55a6 --- /dev/null +++ b/src/unifi_access/schemas/_system_log.py @@ -0,0 +1,78 @@ +import datetime +from enum import StrEnum +from typing import Literal + +from pydantic import Field + +from ._base import ActorId, ForbidExtraBaseModel, UnixTimestampDateTime + + +class SystemLogTopic(StrEnum): + ALL = "all" + """All logs""" + DOOR_OPENINGS = "door_openings" + """Door opening logs""" + CRITICAL = "critical" + """Device restart, deletion, offline status, and detection""" + UPDATES = "updates" + """Device update logs""" + DEVICE_EVENTS = "device_events" + """ + Device online status, device updates, access policy synchronization, + and active and inactive door unlock schedules + """ + ADMIN_ACTIVITY = "admin_activity" + """Admin activity, such as access policy updates, settings changes, and user management""" + VISITOR = "visitor" + """Visitor-related operations""" + + +class SystemLogEvent(ForbidExtraBaseModel): + type: str + display_message: str + reason: str + result: str + published: UnixTimestampDateTime + log_key: str + + +class SystemLogActor(ForbidExtraBaseModel): + id: Literal[""] | ActorId # XXX: check if this is complete + type: str # TODO: at least Literal["user", "service"], maybe more + display_name: str + alternate_id: str # TODO: is this also an ActorId? + alternate_name: str + avatar: str | None = None + sso_picture: str | None = None + + +class SystemLogAuthentication(ForbidExtraBaseModel): + credential_provider: str + issuer: str + + +# TODO: some of these could be narrowed +class SystemLogTarget(ForbidExtraBaseModel): + type: str + id: str + alternate_id: str + alternate_name: str + display_name: str + + +class SystemLogSource(ForbidExtraBaseModel): + actor: SystemLogActor + event: SystemLogEvent + authentication: SystemLogAuthentication | None + target: list[SystemLogTarget] + + +class SystemLogEntry(ForbidExtraBaseModel): + timestamp: datetime.datetime = Field(validation_alias="@timestamp") + id: str = Field(alias="_id") + source: SystemLogSource = Field(alias="_source") + tag: str + + +class FetchSystemLogsResponse(ForbidExtraBaseModel): + hits: list[SystemLogEntry] diff --git a/src/unifi_access/schemas/_user.py b/src/unifi_access/schemas/_user.py new file mode 100644 index 0000000..0c7b2c1 --- /dev/null +++ b/src/unifi_access/schemas/_user.py @@ -0,0 +1,84 @@ +"""3.1 User Schemas""" + +from enum import StrEnum + +from ._access_policy import AccessPolicy +from ._base import ( + ForbidExtraBaseModel, + NfcCardId, + NfcCardToken, + PinCode, + UnixTimestampDateTime, + UserGroupId, + UserId, +) + + +class UserStatus(StrEnum): + ACTIVE = "ACTIVE" + """The user account is in active status.""" + PENDING = "PENDING" + """A new admin account has been invited by the SSO account, but the invitation has not been accepted.""" + DEACTIVATED = "DEACTIVATED" + """The account has been deactivated.""" + + +# TODO: should be a partial of NfcCard +class UserNfcCard(ForbidExtraBaseModel): + id: NfcCardId + """Display ID of the NFC card.""" + token: NfcCardToken + """Unique NFC card token.""" + type: str + """!!! note "not listed in API docs" """ + + +class User(ForbidExtraBaseModel): + id: UserId + """Identity ID of the user.""" + first_name: str + """First name of the user.""" + last_name: str + """Last name of the user.""" + full_name: str + """Full name of the user.""" + alias: str + """Preferred name of the user.""" + user_email: str + """Email of the user.""" + email: str + """!!! note "not listed in API docs, Unclear how this is different from `user_email`" """ + email_status: str + """The status of the user's email.""" + username: str + """!!! note "not listed in API docs" """ + avatar_relative_path: str + """!!! note "not listed in API docs" """ + phone: str + """Contact phone number of the user.""" + employee_number: str + """Employee number of the user.""" + onboard_time: UnixTimestampDateTime + """User onboarding date.""" + status: UserStatus + + +class FullUser(User): + nfc_cards: list[UserNfcCard] + """Token associated with the bound NFC card.""" + pin_code: PinCode | None + """Token associated with the bound PIN code.""" + access_policy_ids: list[str] | None = None + """Collection of the access policy ID.""" + access_policies: list[AccessPolicy] | None = None + """All policies assigned to the user.""" + + +class UserGroup(ForbidExtraBaseModel): + """!!! note "not listed in API docs" """ + + id: UserGroupId + full_name: str + name: str + up_id: UserGroupId + up_ids: list[UserGroupId] diff --git a/src/unifi_access/schemas/_visitor.py b/src/unifi_access/schemas/_visitor.py new file mode 100644 index 0000000..8a4a8b6 --- /dev/null +++ b/src/unifi_access/schemas/_visitor.py @@ -0,0 +1,91 @@ +from enum import StrEnum +from typing import Annotated + +from pydantic import BeforeValidator + +from unifi_access.schemas import UserNfcCard +from unifi_access.schemas._access_policy import PartialSchedule + +from ._base import ( + ForbidExtraBaseModel, + NamedResource, + PinCode, + ScheduleId, + UnixTimestampDateTime, + UserId, + VisitorId, +) + + +class VisitorStatus(StrEnum): + UPCOMING = "UPCOMING" + VISITED = "VISITED" + VISITING = "VISITING" + CANCELLED = "CANCELLED" + NO_VISIT = "NO_VISIT" + ACTIVE = "ACTIVE" + + +class VisitReason(StrEnum): + INTERVIEW = "Interview" + BUSINESS = "Business" + COOPERATION = "Cooperation" + OTHERS = "Others" + + +# TODO: some of these might not be nullable from some endpoints +# Generic to avoid duplication. `fetch_visitor` returns less info in schedule than `get_all_visitors`. +class Visitor(ForbidExtraBaseModel): + id: VisitorId + """Identity ID of the visitor.""" + first_name: str + """First name of the visitor.""" + last_name: str + """Last name of the visitor.""" + status: VisitorStatus + """The visitor's status.""" + inviter_id: UserId | None = None + """Identity ID of the inviter.""" + inviter_name: str | None = None + """Name of the inviter.""" + mobile_phone: str | None = None + """Contact phone number of the visitor.""" + remarks: str | None = None + """Remarks of the visitor.""" + email: str | None = None + """Email of the visitor.""" + visitor_company: str | None = None + """Company of the visitor.""" + visit_reason: VisitReason | None = None + """Visit reason""" + start_time: UnixTimestampDateTime | None = None + """Start time of the visit.""" + end_time: UnixTimestampDateTime | None = None + """End time of the visit.""" + nfc_cards: list[UserNfcCard] + + # coerce empty dict to None, because for some reason that is what `fetch_visitor` returns + pin_code: Annotated[PinCode | None, BeforeValidator(lambda v: v or None)] = None + schedule_id: ScheduleId + """Identity ID of the schedule.""" + schedule: PartialSchedule | None = None + """ + The schedule assigned to the visitor. + If the schedule information is present, it indicates that the visit schedule is recurring. + If the schedule information is not included, it indicates a one-time visit schedule. + """ + resources: list[NamedResource] | None = None + """Specify the locations that the visitor can access.""" + avatar: str | None = None + """!!! note "not listed in API docs" """ + location_id: str | None = None + """!!! note "not listed in API docs" """ + + +class FetchAllVisitorsExpansion(StrEnum): + NONE = "none" + ACCESS_POLICY = "access_policy" + RESOURCE = "resource" + SCHEDULE = "schedule" + NFC_CARD = "nfc_card" + PIN_CODE = "pin_code" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..0d434f7 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,30 @@ +import unittest + +import unifi_access + + +class UnifiAccessTests(unittest.TestCase): + def setUp(self) -> None: + self.host = "localhost:12445" + self.api_token = "EXAMPLE_TOKEN" + self.common_headers = { + "Authorization": f"Bearer {self.api_token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + self.client = unifi_access.AccessClient( + host=self.host, api_token=self.api_token + ) + + # XXX: TODO: + # 11 Notification + # - 11.1 Fetch Notifications [WebSocket] + # - 11.2 List of Supported Webhook Events [Webhook] + # - 11.3 Fetch Webhook Endpoints List [Webhook] + # - 11.4 Add Webhook Endpoints [Webhook] + # - 11.5 Update Webhook Endpoints [Webhook] + # - 11.6 Delete Webhook Endpoints [Webhook] + # - 11.7 Allow Webhook Endpoint Owner to Receive Webhook Events [Webhook] + # 12 API Server + # - 12.1 Upload HTTPS Certificate + # - 12.2 Delete HTTPS Certificate diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..413c980 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +from typing import cast + +import pytest + +from unifi_access import AccessClient + +host = "localhost:12445" +api_token = "EXAMPLE_TOKEN" + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption("--live-access-host", help="Live Unifi Access Host") + parser.addoption("--live-access-api-token", help="Live Unifi Access Host") + + +@pytest.fixture(scope="session") +def live_access_client(request: pytest.FixtureRequest) -> AccessClient: + return AccessClient( + cast(str, request.config.getoption("--live-access-host", skip=True)), + cast(str, request.config.getoption("--live-access-api-token", skip=True)), + verify=False, + ) diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/live/test_access_policy.py b/tests/live/test_access_policy.py new file mode 100644 index 0000000..3901220 --- /dev/null +++ b/tests/live/test_access_policy.py @@ -0,0 +1,87 @@ +from collections.abc import Generator + +import pytest + +from unifi_access import AccessClient +from unifi_access.schemas import AccessPolicy, HolidayGroup, Schedule, WeekSchedule +from unifi_access.schemas._base import DoorResource + + +@pytest.fixture +def holiday_group(live_access_client: AccessClient) -> Generator[HolidayGroup]: + _holiday_group = ( + live_access_client.create_holiday_group("Test Holiday Group") + .success_or_raise() + .data + ) + yield _holiday_group + live_access_client.delete_holiday_group(_holiday_group.id) + + +@pytest.fixture +def schedule( + live_access_client: AccessClient, holiday_group: HolidayGroup +) -> Generator[Schedule]: + _schedule = ( + live_access_client.create_schedule( + "Test Schedule", + week_schedule=WeekSchedule( + sunday=[], + monday=[], + tuesday=[], + wednesday=[], + thursday=[], + friday=[], + saturday=[], + ), + holiday_group_id=holiday_group.id, + ) + .success_or_raise() + .data + ) + yield _schedule + live_access_client.delete_schedule(_schedule.id) + + +@pytest.fixture +def access_policy( + live_access_client: AccessClient, schedule: Schedule +) -> Generator[AccessPolicy]: + _access_policy = ( + live_access_client.create_access_policy("Test Access Policy", schedule.id) + .success_or_raise() + .data + ) + yield _access_policy + live_access_client.delete_access_policy(_access_policy.id) + + +def test_access_policy_lifecycle( + live_access_client: AccessClient, + holiday_group: HolidayGroup, + schedule: Schedule, + access_policy: AccessPolicy, +) -> None: + assert access_policy + doors = live_access_client.fetch_all_doors().success_or_raise().data + resources = [DoorResource(id=door.id) for door in doors] + updated_access_policy = ( + live_access_client.update_access_policy(access_policy.id, resources=resources) + .success_or_raise() + .data + ) + assert updated_access_policy.resources == resources + + updated_holiday_group = ( + live_access_client.update_holiday_group( + holiday_group.id, description="Test Description" + ) + .success_or_raise() + .data + ) + assert updated_holiday_group.description == "Test Description" + + holiday_groups = ( + live_access_client.fetch_all_holiday_groups().success_or_raise().data + ) + assert any(g.id == updated_holiday_group.id for g in holiday_groups) diff --git a/tests/live/test_user_lifecycle.py b/tests/live/test_user_lifecycle.py new file mode 100644 index 0000000..f3ec300 --- /dev/null +++ b/tests/live/test_user_lifecycle.py @@ -0,0 +1,128 @@ +import functools +from collections.abc import Generator +from typing import Protocol + +import pytest + +from unifi_access import AccessClient +from unifi_access.schemas import User, UserGroup, UserGroupId, UserStatus + + +@pytest.fixture(scope="module") +def user(live_access_client: AccessClient) -> User: + # XXX: maybe should test email and employeeNumber, but they are + # unique on the server, and I can't delete users. Maybe + # should fetch user if it already exists? + # TODO: add an onboard time? + return live_access_client.register_user("Test", "User").success_or_raise().data + + +def test_user_lifecycle(live_access_client: AccessClient, user: User) -> None: + assert user.id + assert user.first_name == "Test" + assert user.last_name == "User" + assert user.status == UserStatus.ACTIVE + + # Fetch user + fetched_user = live_access_client.fetch_user(user.id).success_or_raise().data + assert fetched_user.id == user.id + assert fetched_user.first_name == "Test" + assert fetched_user.last_name == "User" + assert fetched_user.status == UserStatus.ACTIVE + + # Update user + live_access_client.update_user( + user.id, last_name="Updated User", status=UserStatus.DEACTIVATED + ).success_or_raise() + + # Fetch again, and check changes + fetched_user = live_access_client.fetch_user(user.id).success_or_raise().data + assert fetched_user.id == user.id + assert fetched_user.first_name == "Test" + assert fetched_user.last_name == "Updated User" + assert fetched_user.status == UserStatus.DEACTIVATED + + # Check for the user in full user list + # TODO: test pagination + all_users = live_access_client.fetch_all_users().success_or_raise().data + matching_user = next(u for u in all_users if u.id == user.id) + assert matching_user.id == user.id + assert matching_user.first_name == "Test" + assert matching_user.last_name == "Updated User" + assert matching_user.status == UserStatus.DEACTIVATED + + +class UserGroupFactory(Protocol): + def __call__( + self, name: str, up_id: UserGroupId | None = None + ) -> UserGroup | None: ... + + +@pytest.fixture +def make_user_group( + live_access_client: AccessClient, +) -> Generator[UserGroupFactory]: + created_groups = [] + + @functools.wraps(live_access_client.create_user_group) + def _make_user_group( + name: str, + up_id: UserGroupId | None = None, + ) -> UserGroup | None: + group = ( + live_access_client.create_user_group(name, up_id).success_or_raise().data + ) + created_groups.append(group) + return group + + yield _make_user_group + + for group in reversed(created_groups): + if group is not None: + live_access_client.delete_user_group(group.id) + + +def test_user_groups_lifecycle( + live_access_client: AccessClient, + make_user_group: UserGroupFactory, + user: User, +) -> None: + # Create group + group = make_user_group("Test Group") + assert group, "group 'Test Group' already existed, please delete" + + # Fetch group + fetched_group = ( + live_access_client.fetch_user_group(group.id).success_or_raise().data + ) + assert fetched_group.name == "Test Group" + + # Check for group in in full group list + groups = live_access_client.fetch_all_user_groups().success_or_raise().data + matching_group = next(g for g in groups if g.id == group.id) + assert matching_group + + # Create child group + child_group = make_user_group("Test Group Child", group.id) + assert child_group, "group 'Test Group / Test Group Child' already existed, which shouldn't be possible" + assert child_group.up_id == group.id + assert child_group.up_ids[-1] == group.id + + # Add user to group + live_access_client.assign_user_to_user_group(group.id, [user.id]) + + # Fetch users in the group + users_in_group = ( + live_access_client.fetch_users_in_a_user_group(group.id).success_or_raise().data + ) + assert len(users_in_group) == 1 + assert users_in_group[0].id == user.id + + # Fetch all users in the group + all_users_in_group = ( + live_access_client.fetch_all_users_in_a_user_group(group.id) + .success_or_raise() + .data + ) + assert len(all_users_in_group) == 1 + assert all_users_in_group[0].id == user.id diff --git a/tests/live/test_visitor_lifecycle.py b/tests/live/test_visitor_lifecycle.py new file mode 100644 index 0000000..159a7ad --- /dev/null +++ b/tests/live/test_visitor_lifecycle.py @@ -0,0 +1,104 @@ +import datetime +import functools +from collections.abc import Callable, Generator + +import pytest + +from unifi_access import AccessClient +from unifi_access.schemas import FetchAllVisitorsExpansion, Visitor, VisitReason + + +@pytest.fixture +def make_visitor(live_access_client: AccessClient) -> Generator[Callable[..., Visitor]]: + created_visitors: list[Visitor] = [] + + @functools.wraps(live_access_client.create_visitor) + def _make_visitor(*args, **kwargs) -> Visitor: + visitor = ( + live_access_client.create_visitor(*args, **kwargs).success_or_raise().data + ) + created_visitors.append(visitor) + return visitor + + yield _make_visitor + + for visitor in created_visitors: + live_access_client.delete_visitor(visitor.id, is_force=True) + + +def test_visitor_lifecycle( + live_access_client: AccessClient, make_visitor: Callable[..., Visitor] +) -> None: + now = datetime.datetime.now(tz=datetime.UTC) + visitor = make_visitor( + "Test", + "Visitor", + start_time=now + datetime.timedelta(days=1), + end_time=now + datetime.timedelta(days=2), + visit_reason=VisitReason.BUSINESS, + ) + assert visitor.first_name == "Test" + + fetched_visitor = ( + live_access_client.fetch_visitor(visitor.id).success_or_raise().data + ) + assert fetched_visitor.first_name == "Test" + assert fetched_visitor.pin_code is None + + # pin code assign/unassign + pin_code = live_access_client.generate_pin_code().success_or_raise().data + live_access_client.assign_pin_code_to_visitor( + visitor.id, pin_code + ).success_or_raise() + fetched_visitor = ( + live_access_client.fetch_visitor(visitor.id).success_or_raise().data + ) + assert fetched_visitor.pin_code + assert fetched_visitor.pin_code.token + live_access_client.unassign_pin_code_from_visitor(visitor.id).success_or_raise() + fetched_visitor = ( + live_access_client.fetch_visitor(visitor.id).success_or_raise().data + ) + assert fetched_visitor.pin_code is None + + updated_visitor = ( + live_access_client.update_visitor(visitor.id, first_name="Updated Test") + .success_or_raise() + .data + ) + assert updated_visitor.first_name == "Updated Test" + + all_visitors = live_access_client.fetch_all_visitors().success_or_raise().data + matched_visitor = next(v for v in all_visitors if v.id == visitor.id) + assert matched_visitor.first_name == "Updated Test" + + expanded_all_visitors = ( + live_access_client.fetch_all_visitors( + expand=[ + FetchAllVisitorsExpansion.ACCESS_POLICY, + FetchAllVisitorsExpansion.RESOURCE, + FetchAllVisitorsExpansion.SCHEDULE, + FetchAllVisitorsExpansion.NFC_CARD, + FetchAllVisitorsExpansion.PIN_CODE, + ] + ) + .success_or_raise() + .data + ) + expanded_matched_visitor = next( + v for v in expanded_all_visitors if v.id == visitor.id + ) + assert expanded_matched_visitor.first_name == "Updated Test" + # TODO: test expanded contents + + non_expanded_all_visitors = ( + live_access_client.fetch_all_visitors(expand=[FetchAllVisitorsExpansion.NONE]) + .success_or_raise() + .data + ) + non_expanded_matched_visitor = next( + v for v in non_expanded_all_visitors if v.id == visitor.id + ) + assert non_expanded_matched_visitor.first_name == "Updated Test" + assert non_expanded_matched_visitor.schedule is None + # TODO: test other non-expanded contents diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py new file mode 100644 index 0000000..c4593df --- /dev/null +++ b/tests/test_access_policy.py @@ -0,0 +1,869 @@ +import datetime + +import responses +from responses import matchers + +from unifi_access.schemas import ( + AccessPolicyId, + DoorGroupId, + DoorGroupResource, + DoorId, + DoorResource, + HolidayGroupId, + HolidayId, + PartialHoliday, + ScheduleId, + TimePeriod, + WeekSchedule, +) + +from .base import UnifiAccessTests + + +class AccessPolicyTests(UnifiAccessTests): + @responses.activate + def test_create_access_policy(self) -> None: + """5.2 Create Access Policy""" + responses.post( + f"https://{self.host}/api/v1/developer/access_policies", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "test", + "resource": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + "schedule_id": "4e108aeb-ec9a-4822-bf86-170ea986f934", + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "id": "bb5eb965-42dc-4206-9654-88a2d1c3aaa5", + "name": "test", + "resources": [ + {"id": "6ff875d2-af87-470b-9cb5-774c6596afc8", "type": "door"}, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + "schedule_id": "4e108aeb-ec9a-4822-bf86-170ea986f934", + }, + "msg": "success", + }, + ) + + resp = self.client.create_access_policy( + name="test", + resources=[ + DoorResource( + id=DoorId("6ff875d2-af87-470b-9cb5-774c6596afc8"), + ), + DoorGroupResource( + id=DoorGroupId("5c496423-6d25-4e4f-8cdf-95ad5135188a"), + ), + DoorGroupResource( + id=DoorGroupId("d5573467-d6b3-4e8f-8e48-8a322b91664a"), + ), + ], + schedule_id=ScheduleId("4e108aeb-ec9a-4822-bf86-170ea986f934"), + ).success_or_raise() + assert resp.data.id == "bb5eb965-42dc-4206-9654-88a2d1c3aaa5" + + @responses.activate + def test_update_access_policy(self) -> None: + """5.3 Update Access Policy""" + access_policy_id = AccessPolicyId("242c88e3-0524-42de-8447-45891c5df714") + responses.put( + f"https://{self.host}/api/v1/developer/access_policies/{access_policy_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "test", + "resource": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + "schedule_id": "4e108aeb-ec9a-4822-bf86-170ea986f934", + } + ), + ], + # NOTE: no example provided in docs, using the same one as `create` + json={ + "code": "SUCCESS", + "data": { + "id": "bb5eb965-42dc-4206-9654-88a2d1c3aaa5", + "name": "test", + "resources": [ + {"id": "6ff875d2-af87-470b-9cb5-774c6596afc8", "type": "door"}, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + "schedule_id": "4e108aeb-ec9a-4822-bf86-170ea986f934", + }, + "msg": "success", + }, + ) + + resp = self.client.update_access_policy( + access_policy_id=access_policy_id, + name="test", + resources=[ + DoorResource( + id=DoorId("6ff875d2-af87-470b-9cb5-774c6596afc8"), + ), + DoorGroupResource( + id=DoorGroupId("5c496423-6d25-4e4f-8cdf-95ad5135188a"), + ), + DoorGroupResource( + id=DoorGroupId("d5573467-d6b3-4e8f-8e48-8a322b91664a"), + ), + ], + schedule_id=ScheduleId("4e108aeb-ec9a-4822-bf86-170ea986f934"), + ).success_or_raise() + assert resp.data.id == "bb5eb965-42dc-4206-9654-88a2d1c3aaa5" + + @responses.activate + def test_delete_access_policy(self) -> None: + """5.4 Delete Access Policy""" + access_policy_id = AccessPolicyId("60d0bcc-5d4f-4e7b-8a3c-8d4502765e11") + responses.delete( + f"https://{self.host}/api/v1/developer/access_policies/{access_policy_id}", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "msg": "success", "data": "success"}, + ) + + resp = self.client.delete_access_policy( + access_policy_id=access_policy_id + ).success_or_raise() + + assert resp.data == "success" + + @responses.activate + def test_fetch_access_policy(self) -> None: + """5.5 Fetch Access Policy""" + access_policy_id = AccessPolicyId("ed09985f-cf52-486e-bc33-377b6ed7bbf2") + responses.get( + f"https://{self.host}/api/v1/developer/access_policies/{access_policy_id}", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "id": "ed09985f-cf52-486e-bc33-377b6ed7bbf2", + "name": "test11", + "resources": [ + {"id": "6ff875d2-af87-470b-9cb5-774c6596afc8", "type": "door"}, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + "schedule_id": "4e108aeb-ec9a-4822-bf86-170ea986f934", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_access_policy( + access_policy_id=access_policy_id + ).success_or_raise() + + assert resp.data.id == "ed09985f-cf52-486e-bc33-377b6ed7bbf2" + + @responses.activate + def test_fetch_all_access_policies(self) -> None: + """5.6 Fetch All Access Policies""" + responses.get( + f"https://{self.host}/api/v1/developer/access_policies", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "id": "73f15cab-c725-4a76-a419-a4026d131e96", + "name": "Default Admin Policy", + "resources": [ + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + ], + "schedule_id": "73facd6c-839e-4521-a4f4-c07e1d44e748", + }, + { + "id": "b96948a4-fed9-40a3-9c4a-e473822a3db7", + "name": "Default UNVR Policy", + "resources": [ + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + ], + "schedule_id": "58c0f89b-f35c-4d2c-af7b-8b8918df2c31", + }, + { + "id": "edbc80df-3698-49fd-8b53-f1867f104947", + "name": "TEST", + "resources": [ + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + ], + "schedule_id": "73facd6c-839e-4521-a4f4-c07e1d44e748", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_access_policies().success_or_raise() + assert resp.data[0].id == "73f15cab-c725-4a76-a419-a4026d131e96" + + @responses.activate + def test_create_holiday_group(self) -> None: + """5.8 Create Holiday Group""" + responses.post( + f"https://{self.host}/api/v1/developer/access_policies/holiday_groups", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "Holiday Group-169286791557142", + "holidays": [ + { + "name": "Holiday Name 1", + "description": "", + "repeat": False, + "start_time": "2023-08-25T00:00:00Z", + "end_time": "2023-08-26T00:00:00Z", + }, + { + "name": "Holiday Name 2", + "description": "", + "repeat": False, + "start_time": "2023-08-26T00:00:00Z", + "end_time": "2023-08-27T00:00:00Z", + }, + ], + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "description": "", + "holidays": [ + { + "description": "", + "end_time": "2023-08-26 00:00:00Z", + "id": "8900533d-03be-4f84-832d-54ff59905759", + "name": "Holiday Name 1", + "repeat": False, + "start_time": "2023-08-25 00:00:00Z", + }, + { + # NOTE: duplicated key in sample data + # "name": "holiday-2023-08-26", + "end_time": "2023-08-27 00:00:00Z", + "id": "9fff81cc-d476-40c4-80f9-d510451ce2cd", + "name": "Holiday Name 2", + "repeat": False, + "start_time": "2023-08-26 00:00:00Z", + }, + ], + "id": "7be7a7a0-818f-4f76-98c3-1c38957f4dca", + "is_default": False, + "name": "Holiday Group-169286791557142", + "template_name": "", + }, + "msg": "success", + }, + ) + + resp = self.client.create_holiday_group( + name="Holiday Group-169286791557142", + holidays=[ + PartialHoliday( + name="Holiday Name 1", + description="", + repeat=False, + start_time=datetime.datetime(2023, 8, 25, tzinfo=datetime.UTC), + end_time=datetime.datetime(2023, 8, 26, tzinfo=datetime.UTC), + ), + PartialHoliday( + name="Holiday Name 2", + description="", + repeat=False, + start_time=datetime.datetime(2023, 8, 26, tzinfo=datetime.UTC), + end_time=datetime.datetime(2023, 8, 27, tzinfo=datetime.UTC), + ), + ], + ).success_or_raise() + assert resp.data.id == "7be7a7a0-818f-4f76-98c3-1c38957f4dca" + + @responses.activate + def test_update_holiday_group(self) -> None: + """5.9 Update Holiday Group""" + holiday_group_id = HolidayGroupId("7be7a7a0-818f-4f76-98c3-1c38957f4dca") + responses.put( + f"https://{self.host}/api/v1/developer/access_policies/holiday_groups/{holiday_group_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "Holiday Group-169286791557142", + "holidays": [ + # add a new holiday + { + "name": "Holiday Name 1", + "description": "", + "repeat": False, + "start_time": "2023-08-25T00:00:00Z", + "end_time": "2023-08-26T00:00:00Z", + }, + # update an existing holiday + { + "id": "d23a4226-765f-4967-b84f-6dfd53f33c89", + "name": "Holiday Name 2", + "description": "", + "repeat": False, + "start_time": "2023-08-26T00:00:00Z", + "end_time": "2023-08-27T00:00:00Z", + }, + ], + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "description": "", + "holidays": [ + { + "description": "", + "end_time": "2023-08-26 00:00:00Z", + "id": "8900533d-03be-4f84-832d-54ff59905759", + "name": "Holiday Name 1", + "repeat": False, + "start_time": "2023-08-25 00:00:00Z", + }, + { + "description": "", + "end_time": "2023-08-27 00:00:00Z", + "id": "9fff81cc-d476-40c4-80f9-d510451ce2cd", + "name": "Holiday Name 2", + "repeat": False, + "start_time": "2023-08-26 00:00:00Z", + }, + ], + "id": "7be7a7a0-818f-4f76-98c3-1c38957f4dca", + "is_default": False, + "name": "Holiday Group-169286791557142", + "template_name": "", + }, + "msg": "success", + }, + ) + + resp = self.client.update_holiday_group( + holiday_group_id=holiday_group_id, + name="Holiday Group-169286791557142", + holidays=[ + # add a new holiday + PartialHoliday( + name="Holiday Name 1", + description="", + repeat=False, + start_time=datetime.datetime(2023, 8, 25, tzinfo=datetime.UTC), + end_time=datetime.datetime(2023, 8, 26, tzinfo=datetime.UTC), + ), + # update an existing holiday + PartialHoliday( + id=HolidayId("d23a4226-765f-4967-b84f-6dfd53f33c89"), + name="Holiday Name 2", + description="", + repeat=False, + start_time=datetime.datetime(2023, 8, 26, tzinfo=datetime.UTC), + end_time=datetime.datetime(2023, 8, 27, tzinfo=datetime.UTC), + ), + ], + ).success_or_raise() + assert resp.data.id == "7be7a7a0-818f-4f76-98c3-1c38957f4dca" + + @responses.activate + def test_delete_holiday_group(self) -> None: + """5.10 Delete Holiday Group""" + holiday_group_id = HolidayGroupId("7be7a7a0-818f-4f76-98c3-1c38957f4dca") + + responses.delete( + f"https://{self.host}/api/v1/developer/access_policies/holiday_groups/{holiday_group_id}", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "msg": "success", "data": "success"}, + ) + + resp = self.client.delete_holiday_group( + holiday_group_id=holiday_group_id + ).success_or_raise() + + assert resp.data == "success" + + @responses.activate + def test_fetch_holiday_group(self) -> None: + """5.11 Fetch Holiday Group""" + holiday_group_id = HolidayGroupId("7be7a7a0-818f-4f76-98c3-1c38957f4dca") + responses.get( + f"https://{self.host}/api/v1/developer/access_policies/holiday_groups/{holiday_group_id}", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "description": "", + "holidays": [ + { + "description": "", + "end_time": "2023-08-26 00:00:00Z", + "id": "8900533d-03be-4f84-832d-54ff59905759", + "name": "Holiday Name 1", + "repeat": False, + "start_time": "2023-08-25 00:00:00Z", + }, + { + "description": "", + "end_time": "2023-08-27 00:00:00Z", + "id": "9fff81cc-d476-40c4-80f9-d510451ce2cd", + "name": "Holiday Name 2", + "repeat": False, + "start_time": "2023-08-26 00:00:00Z", + }, + ], + "id": "7be7a7a0-818f-4f76-98c3-1c38957f4dca", + "is_default": False, + "name": "Holiday Group-169286791557142", + "template_name": "", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_holiday_group( + holiday_group_id=holiday_group_id + ).success_or_raise() + assert resp.data.id == "7be7a7a0-818f-4f76-98c3-1c38957f4dca" + + @responses.activate + def test_fetch_all_holiday_groups(self) -> None: + """5.12 Fetch All Holiday Groups""" + responses.get( + f"https://{self.host}/api/v1/developer/access_policies/holiday_groups", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "count": 0, + "description": "", + "id": "8cc22b49-a7f4-49a6-9f04-044444992d6c", + "is_default": True, + "name": "No Holidays", + }, + { + "count": 2, + "description": "", + "id": "86c634da-7b2c-411c-a2c1-1495d089c719", + "is_default": False, + "name": "Holiday Group-1692867312225", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_holiday_groups().success_or_raise() + assert resp.data[0].id == "8cc22b49-a7f4-49a6-9f04-044444992d6c" + + @responses.activate + def test_create_schedule(self) -> None: + """5.14 Create Schedule""" + responses.post( + f"https://{self.host}/api/v1/developer/access_policies/schedules", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "schedule-1688977094169", + "week_schedule": { + "sunday": [], + "monday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "tuesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "wednesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "thursday": [], + "friday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "saturday": [], + }, + "holiday_group_id": "75660081-431b-4dbe-9b98-e0257877118e", + "holiday_schedule": [ + {"start_time": "03:15:00", "end_time": "11:45:59"}, + {"start_time": "15:00:00", "end_time": "19:00:59"}, + ], + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "id": "1d31b648-b8ff-4bd1-b742-60dbd70592cd", + "is_default": False, + "name": "schedule-1688977094169", + "type": "access", + "weekly": { + "friday": [{"end_time": "17:00:59", "start_time": "10:00:00"}], + "monday": [{"end_time": "17:00:59", "start_time": "10:00:00"}], + "saturday": [], + "sunday": [], + "thursday": [], + "tuesday": [{"end_time": "17:00:59", "start_time": "10:00:00"}], + "wednesday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + }, + "holiday_group_id": "75660081-431b-4dbe-9b98-e0257877118e", + "holiday_group": { + "description": "", + "holidays": [ + { + "description": "", + "end_time": "2023-08-26 00:00:00Z", + "id": "d51777c4-9559-45aa-8e23-434995d9d2a1", + "is_template": False, + "name": "Holiday Name 1", + "repeat": False, + "start_time": "2023-08-25 00:00:00Z", + }, + { + "description": "", + "end_time": "2023-08-27 00:00:00Z", + "id": "d23a4226-765f-4967-b84f-6dfd53f33c89", + "is_template": False, + "name": "Holiday Name 2", + "repeat": False, + "start_time": "2023-08-26 00:00:00Z", + }, + ], + "id": "75660081-431b-4dbe-9b98-e0257877118e", + "is_default": False, + "name": "Holiday Group-1692867915571423", + "template_name": "", + }, + "holiday_schedule": [ + {"start_time": "03:15:00", "end_time": "11:45:59"}, + {"start_time": "15:00:00", "end_time": "19:00:59"}, + ], + }, + "msg": "success", + }, + ) + + resp = self.client.create_schedule( + name="schedule-1688977094169", + week_schedule=WeekSchedule( + sunday=[], + monday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + tuesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + wednesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + thursday=[], + friday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + saturday=[], + ), + holiday_group_id=HolidayGroupId("75660081-431b-4dbe-9b98-e0257877118e"), + holiday_schedule=[ + TimePeriod( + start_time=datetime.time(3, 15, 0), + end_time=datetime.time(11, 45, 59), + ), + TimePeriod( + start_time=datetime.time(15, 0, 0), + end_time=datetime.time(19, 0, 59), + ), + ], + ).success_or_raise() + + @responses.activate + def test_update_schedule(self) -> None: + """5.15 Update Schedule""" + schedule_id = ScheduleId("1d31b648-b8ff-4bd1-b742-60dbd70592cd") + responses.put( + f"https://{self.host}/api/v1/developer/access_policies/schedules/{schedule_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "schedule-1688977094169", + "holiday_group_id": "75660081-431b-4dbe-9b98-e0257877118e", + "week_schedule": { + "sunday": [], + "monday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "tuesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "wednesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "thursday": [ + {"start_time": "10:00:00", "end_time": "17:01:59"} + ], + "friday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "saturday": [], + }, + "holiday_schedule": [ + {"start_time": "03:15:00", "end_time": "11:45:59"} + ], + } + ), + ], + json={"code": "SUCCESS", "data": {}, "msg": "success"}, + ) + + resp = self.client.update_schedule( + schedule_id=schedule_id, + name="schedule-1688977094169", + holiday_group_id=HolidayGroupId("75660081-431b-4dbe-9b98-e0257877118e"), + week_schedule=WeekSchedule( + sunday=[], + monday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + tuesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + wednesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + thursday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 1, 59), + ) + ], + friday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + saturday=[], + ), + holiday_schedule=[ + TimePeriod( + start_time=datetime.time(3, 15, 0), + end_time=datetime.time(11, 45, 59), + ) + ], + ).success_or_raise() + + @responses.activate + def test_fetch_schedule(self) -> None: + """5.16 Fetch Schedule""" + schedule_id = ScheduleId("1d31b648-b8ff-4bd1-b742-60dbd70592cd") + + responses.get( + f"https://{self.host}/api/v1/developer/access_policies/schedules/{schedule_id}", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "id": "1d31b648-b8ff-4bd1-b742-60dbd70592cd", + "is_default": False, + "name": "schedule-1688977094169", + "type": "access", + "weekly": { + "friday": [{"end_time": "17:00:59", "start_time": "10:00:00"}], + "monday": [{"end_time": "17:00:59", "start_time": "10:00:00"}], + "saturday": [], + "sunday": [], + "thursday": [ + {"end_time": "17:01:59", "start_time": "10:00:00"} + ], + "tuesday": [{"end_time": "17:00:59", "start_time": "10:00:00"}], + "wednesday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + }, + "holiday_group_id": "75660081-431b-4dbe-9b98-e0257877118e", + "holiday_group": { + "description": "", + "holidays": [ + { + "description": "", + "end_time": "2023-08-26 00:00:00Z", + "id": "d51777c4-9559-45aa-8e23-434995d9d2a1", + "is_template": False, + "name": "Holiday Name 1", + "repeat": False, + "start_time": "2023-08-25 00:00:00Z", + }, + { + "description": "", + "end_time": "2023-08-27 00:00:00Z", + "id": "d23a4226-765f-4967-b84f-6dfd53f33c89", + "is_template": False, + "name": "Holiday Name 2", + "repeat": False, + "start_time": "2023-08-26 00:00:00Z", + }, + ], + "id": "75660081-431b-4dbe-9b98-e0257877118e", + "is_default": False, + "name": "Holiday Group-16928679155714", + "template_name": "", + }, + "holiday_schedule": [ + {"end_time": "11:45:59", "start_time": "09:15:00"} + ], + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_schedule(schedule_id=schedule_id).success_or_raise() + + @responses.activate + def test_fetch_all_schedules(self) -> None: + """5.17 Fetch All Schedules""" + responses.get( + f"https://{self.host}/api/v1/developer/access_policies/schedules", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "id": "73facd6c-839e-4521-a4f4-c07e1d44e748", + "holiday_group_id": "75660081-431b-4dbe-9b98-e0257877118e", + "is_default": True, + "name": "Always Access", + "status": 1, + "type": "access", + }, + { + "id": "58c0f89b-f35c-4d2c-af7b-8b8918df2c31", + "holiday_group_id": "75660081-431b-4dbe-9b98-e0257877118e", + "is_default": False, + "name": "UNVR Schedule", + "status": 1, + "type": "access", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_schedules().success_or_raise() + + @responses.activate + def test_delete_schedule(self) -> None: + """5.18 Delete Schedule""" + schedule_id = ScheduleId("1d31b648-b8ff-4bd1-b742-60dbd70592cd") + + responses.delete( + f"https://{self.host}/api/v1/developer/access_policies/schedules/{schedule_id}", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "msg": "success", "data": "success"}, + ) + + resp = self.client.delete_schedule(schedule_id=schedule_id).success_or_raise() diff --git a/tests/test_credential.py b/tests/test_credential.py new file mode 100644 index 0000000..6c0eb78 --- /dev/null +++ b/tests/test_credential.py @@ -0,0 +1,189 @@ +import responses +from responses import matchers + +from unifi_access.schemas import DeviceId, NfcCardEnrollmentSessionId, NfcCardToken + +from .base import UnifiAccessTests + + +class CredentialTests(UnifiAccessTests): + @responses.activate + def test_generate_pin_code(self) -> None: + """6.1 Generate PIN Code""" + responses.post( + f"https://{self.host}/api/v1/developer/credentials/pin_codes", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "data": "67203419", "msg": "success"}, + ) + resp = self.client.generate_pin_code().success_or_raise() + assert resp.data == "67203419" + + @responses.activate + def test_begin_enroll_card(self) -> None: + """6.2 Enroll NFC Card""" + responses.post( + f"https://{self.host}/api/v1/developer/credentials/nfc_cards/sessions", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + {"device_id": "0418d6a2bb7a", "reset_ua_card": False} + ), + ], + json={ + "code": "SUCCESS", + "msg": "success", + "data": {"session_id": "e8a97c52-6676-4c48-8589-bd518afc4094"}, + }, + ) + resp = self.client.begin_enroll_card( + device_id=DeviceId("0418d6a2bb7a"), reset_ua_card=False + ).success_or_raise() + assert resp.data.session_id == "e8a97c52-6676-4c48-8589-bd518afc4094" + + @responses.activate + def test_fetch_enroll_card_status(self) -> None: + """6.3 Fetch NFC Card Enrollment Status""" + session_id = NfcCardEnrollmentSessionId("e8a97c52-6676-4c48-8589-bd518afc4094") + responses.get( + f"https://{self.host}/api/v1/developer/credentials/nfc_cards/sessions/{session_id}", + match=[ + matchers.header_matcher(self.common_headers), + ], + json={ + "code": "SUCCESS", + "msg": "success", + "data": { + # NOTE: altered from API docs to match testing on real instance (was "card_id") + "id": "014A3151", + "token": "821f90b262e90c5c0fbcddf3d6d2f3b94cc015d6e8104ab4fb96e4c8b8e90cb7", + }, + }, + ) + resp = self.client.fetch_enroll_card_status( + session_id=session_id + ).success_or_raise() + assert resp.data.id == "014A3151" + assert ( + resp.data.token + == "821f90b262e90c5c0fbcddf3d6d2f3b94cc015d6e8104ab4fb96e4c8b8e90cb7" + ) + + @responses.activate + def test_remove_enrollment_session(self) -> None: + """6.4 Remove a Session Created for NFC Card Enrollment""" + session_id = NfcCardEnrollmentSessionId("e8a97c52-6676-4c48-8589-bd518afc4094") + responses.delete( + f"https://{self.host}/api/v1/developer/credentials/nfc_cards/sessions/{session_id}", + match=[ + matchers.header_matcher(self.common_headers), + ], + json={"code": "SUCCESS", "msg": "success", "data": "success"}, + ) + resp = self.client.remove_enrollment_session( + session_id=session_id + ).success_or_raise() + + @responses.activate + def test_fetch_nfc_card(self) -> None: + """6.7 Fetch NFC Card""" + nfc_card_token = NfcCardToken( + "f77d69b08eaf5eb5d647ac1a0a73580f1b27494b345f40f54fa022a8741fa15c" + ) + responses.get( + f"https://{self.host}/api/v1/developer/credentials/nfc_cards/tokens/{nfc_card_token}", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "alias": "", + "card_type": "ua_card", + "display_id": "100005", + "note": "100005", + "status": "assigned", + "token": "f77d69b08eaf5eb5d647ac1a0a73580f1b27494b345f40f54fa022a8741fa15c", + "user": { + "avatar": "", + "first_name": "H", + "id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + "last_name": "L", + "name": "H L", + }, + "user_id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + "user_type": "USER", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_nfc_card(nfc_card_token).success_or_raise() + + @responses.activate + def test_fetch_all_nfc_cards(self) -> None: + """6.8 Fetch All NFC Cards""" + responses.get( + f"https://{self.host}/api/v1/developer/credentials/nfc_cards/tokens", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"page_num": 1, "page_size": 25}), + ], + json={ + "code": "SUCCESS", + "data": [ + { + "alias": "", + "card_type": "ua_card", + "display_id": "100004", + "note": "100004", + "status": "assigned", + "token": "9e24cdfafebf63e58fd02c5f67732b478948e5793d31124239597d9a86b30dc4", + "user": { + "avatar": "", + "first_name": "H", + "id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + "last_name": "L", + "name": "H L", + }, + "user_id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + "user_type": "USER", + }, + { + "alias": "F77D69B03", + "card_type": "ua_card", + "display_id": "100005", + "note": "100005", + "status": "assigned", + "token": "f77d69b08eaf5eb5d647ac1a0a73580f1b27494b345f40f54fa022a8741fa15c", + "user": { + "avatar": "", + "first_name": "H2", + "id": "34dc90a7-409f-4bf8-a5a8-1c59535a21b9", + "last_name": "L", + "name": "H2 L", + }, + "user_id": "34dc90a7-409f-4bf8-a5a8-1c59535a21b9", + "user_type": "VISITOR", + }, + ], + "msg": "succ", + "pagination": {"page_num": 1, "page_size": 2, "total": 2}, + }, + ) + + resp = self.client.fetch_all_nfc_cards( + page_num=1, page_size=25 + ).success_or_raise() + + @responses.activate + def test_delete_nfc_card(self) -> None: + """6.9 Delete NFC Card""" + nfc_card_token = NfcCardToken( + "f77d69b08eaf5eb5d647ac1a0a73580f1b27494b345f40f54fa022a8741fa15c" + ) + responses.delete( + f"https://{self.host}/api/v1/developer/credentials/nfc_cards/tokens/{nfc_card_token}", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "data": "success", "msg": "success"}, + ) + + resp = self.client.delete_nfc_card(nfc_card_token).success_or_raise() + assert resp.data == "success" diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..cee4325 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,42 @@ +import responses +from responses import matchers + +from .base import UnifiAccessTests + + +class CredentialTests(UnifiAccessTests): + @responses.activate + def test_fetch_devices(self) -> None: + """8.1 Fetch Devices""" + responses.get( + f"https://{self.host}/api/v1/developer/devices", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + # XXX: verify correct response, as per note + # NOTE: api docs had this as a list of lists of objects + "data": [ + { + "full_name": "UNVR - Main Floor - Door 3855 - UA-HUB-3855", + "id": "7483c2773855", + "name": "UA-HUB-3855", + "type": "UAH", + }, + { + "full_name": "UNVR - Main Floor - Door 3855 - out - UA-LITE-8CED", + "id": "f492bfd28ced", + "name": "UA-LITE-8CED", + "type": "UDA-LITE", + }, + { + "full_name": "UNVR - Main Floor - Door 3855 - in - UA-G2-PRO-BB7A", + "id": "0418d6a2bb7a", + "name": "UA-G2-PRO-BB7A", + "type": "UA-G2-PRO", + }, + ], + "msg": "success", + }, + ) + resp = self.client.fetch_devices().success_or_raise() + assert resp.data[0].name == "UA-HUB-3855" diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 0000000..d326368 --- /dev/null +++ b/tests/test_space.py @@ -0,0 +1,434 @@ +import responses +from responses import matchers + +from unifi_access.schemas import DoorGroupId, DoorId, DoorLockingRuleType + +from .base import UnifiAccessTests + + +class SpaceTests(UnifiAccessTests): + @responses.activate + def test_fetch_door_group_topology(self) -> None: + """7.1 Fetch Door Group Topology""" + responses.get( + f"https://{self.host}/api/v1/developer/door_groups/topology", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "name": "All Doors", + "resource_topologies": [ + { + "id": "9bee6e0e-108d-4c52-9107-76f2c7dea4f1", + "name": "Main Floor", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "name": "Door 3855", + "type": "door", + "is_bind_hub": True, + } + ], + "type": "floor", + } + ], + "type": "building", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "name": "customized group", + "resource_topologies": [ + { + "id": "9bee6e0e-108d-4c52-9107-76f2c7dea4f1", + "name": "Main Floor", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "name": "Door 3855", + "type": "door", + "is_bind_hub": True, + } + ], + "type": "floor", + } + ], + "type": "access", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_door_group_topology().success_or_raise() + + @responses.activate + def test_create_door_group(self) -> None: + """7.2 Create Door Group""" + responses.post( + f"https://{self.host}/api/v1/developer/door_groups", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "group_name": "Test", + "resources": ["6ff875d2-af87-470b-9cb5-774c6596afc8"], + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "id": "0140fa3d-8973-4305-a0ce-5306ae277878", + "name": "Customized Door Group", + "resources": [ + {"id": "6ff875d2-af87-470b-9cb5-774c6596afc8", "type": "door"} + ], + "type": "access", + }, + "msg": "success", + }, + ) + + # XXX: TODO: test if this supports both doors and door groups + resp = self.client.create_door_group( + group_name="Test", + resources=[DoorId("6ff875d2-af87-470b-9cb5-774c6596afc8")], + ).success_or_raise() + + @responses.activate + def test_fetch_door_group_building(self) -> None: + """7.3 Fetch Door Group""" + door_group_id = DoorGroupId("d5573467-d6b3-4e8f-8e48-8a322b91664a") + responses.get( + f"https://{self.host}/api/v1/developer/door_groups/{door_group_id}", + match=[matchers.header_matcher(self.common_headers)], + # Group type is building + json={ + "code": "SUCCESS", + "data": { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "name": "All Doors", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "name": "Door 3855", + "type": "door", + }, + { + "id": "7cc1823f-9cdb-447b-b01b-4cb2abc661c0", + "name": "A2 Door", + "type": "door", + }, + { + "id": "ececa68e-239f-4b82-adc4-0c9ce70c60ff", + "name": "A3", + "type": "door", + }, + ], + "type": "building", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_door_group( + door_group_id=door_group_id + ).success_or_raise() + + @responses.activate + def test_fetch_door_group_customized_groups(self) -> None: + """7.3 Fetch Door Group""" + door_group_id = DoorGroupId("1be0c995-0347-4cb2-93b3-66a9624af568") + responses.get( + f"https://{self.host}/api/v1/developer/door_groups/{door_group_id}", + match=[matchers.header_matcher(self.common_headers)], + # Customized groups + json={ + "code": "SUCCESS", + "data": { + "id": "1be0c995-0347-4cb2-93b3-66a9624af568", + "name": "Door Group 01", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + "name": "Door 385", + } + ], + "type": "access", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_door_group( + door_group_id=door_group_id + ).success_or_raise() + + @responses.activate + def test_update_door_group(self) -> None: + """7.4 Update Door Group""" + door_group_id = DoorGroupId("0140fa3d-8973-4305-a0ce-5306ae277878") + responses.put( + f"https://{self.host}/api/v1/developer/door_groups/{door_group_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "resources": [ + "6ff875d2-af87-470b-9cb5-774c6596afc8", + "5a2c3d4e-1f6b-4c8d-9e0f-2a3b4c5d6e7f", + "2p3q4r5s-6t7u-8v9w-x0y1-z2a3b4c5d6e", + ] + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "id": "0140fa3d-8973-4305-a0ce-5306ae277878", + "name": "test", + "resources": [ + {"id": "6ff875d2-af87-470b-9cb5-774c6596afc8", "type": "door"} + ], + "type": "access", + }, + "msg": "success", + }, + ) + + # XXX: TODO: verify that this accepts both Doors and Door Groups + resp = self.client.update_door_group( + door_group_id=door_group_id, + resources=[ + DoorId("6ff875d2-af87-470b-9cb5-774c6596afc8"), + DoorId("5a2c3d4e-1f6b-4c8d-9e0f-2a3b4c5d6e7f"), + DoorGroupId("2p3q4r5s-6t7u-8v9w-x0y1-z2a3b4c5d6e"), + ], + ).success_or_raise() + + @responses.activate + def test_update_door_group_delete_resources(self) -> None: + """7.4 Update Door Group""" + door_group_id = DoorGroupId("0140fa3d-8973-4305-a0ce-5306ae277878") + responses.put( + f"https://{self.host}/api/v1/developer/door_groups/{door_group_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher({"resources": []}), + ], + # NOTE: no example provided in API docs + json={ + "code": "SUCCESS", + "data": { + "id": "0140fa3d-8973-4305-a0ce-5306ae277878", + "name": "test", + "resources": [], + "type": "access", + }, + "msg": "success", + }, + ) + + resp = self.client.update_door_group( + door_group_id=door_group_id, + resources=[], + ).success_or_raise() + + @responses.activate + def test_fetch_all_door_groups(self) -> None: + """7.5 Fetch All Door Groups""" + responses.get( + f"https://{self.host}/api/v1/developer/door_groups", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "name": "Test", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + } + ], + "type": "access", + }, + { + "id": "1907cc46-0a73-4077-94c1-95b625bdb0f8", + "name": "Test2", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + } + ], + "type": "access", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_door_groups().success_or_raise() + + @responses.activate + def test_delete_door_groups(self) -> None: + """7.6 Delete Door Group""" + door_group_id = DoorGroupId("0140fa3d-8973-4305-a0ce-5306ae277878") + responses.get( + f"https://{self.host}/api/v1/developer/door_groups/{door_group_id}", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "data": "success", "msg": "success"}, + ) + + resp = self.client.delete_door_group( + door_group_id=door_group_id + ).success_or_raise() + + @responses.activate + def test_fetch_door(self) -> None: + """7.7 Fetch Door""" + door_id = DoorId("0ed545f8-2fcd-4839-9021-b39e707f6aa9") + responses.get( + f"https://{self.host}/api/v1/developer/doors/{door_id}", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "door_lock_relay_status": "lock", + "door_position_status": "", + "floor_id": "3275af8d-3fa7-4902-a11b-011e41c8464a", + "full_name": "UNVR - 1F - Main Door", + "id": "0ed545f8-2fcd-4839-9021-b39e707f6aa9", + "is_bind_hub": True, + "name": "Main Door", + "type": "door", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_door(door_id=door_id).success_or_raise() + + @responses.activate + def test_fetch_all_doors(self) -> None: + """7.8 Fetch All Doors""" + responses.get( + f"https://{self.host}/api/v1/developer/doors", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "door_lock_relay_status": "unlock", + "door_position_status": "open", + "floor_id": "23c5db06-b59b-494d-94f1-23e88fbe4909", + "full_name": "UNVR - 2F - A2 Door", + "id": "0ed545f8-2fcd-4839-9021-b39e707f6aa9", + "is_bind_hub": True, + "name": "A2 Door", + "type": "door", + }, + { + "door_lock_relay_status": "lock", + "door_position_status": "close", + "floor_id": "7c62b4b3-692f-44ea-8eb8-e212833b4e0f", + "full_name": "UNVR - 1F - Door 3855", + "id": "5785e97b-6123-4596-ba49-b6e51164db9b", + "is_bind_hub": True, + "name": "Door 3855", + "type": "door", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_doors().success_or_raise() + + @responses.activate + def test_unlock_door(self) -> None: + """7.9 Remote Door Unlocking""" + door_id = DoorId("5785e97b-6123-4596-ba49-b6e51164db9b") + responses.put( + f"https://{self.host}/api/v1/developer/doors/{door_id}/unlock", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "data": "success", "msg": "success"}, + ) + + resp = self.client.unlock_door(door_id=door_id).success_or_raise() + + @responses.activate + def test_set_temporary_door_locking_rule(self) -> None: + """7.10 Set Temporary Door Locking Rule""" + door_id = DoorId("e4978b83-203d-4015-97df-b86efc91cb0c") + responses.put( + f"https://{self.host}/api/v1/developer/doors/{door_id}/lock_rule", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher({"type": "custom", "interval": 10}), + ], + json={"code": "SUCCESS", "data": "success", "msg": "success"}, + ) + + # Customized 10-minute unlocked + resp = self.client.set_temporary_door_locking_rule( + door_id=door_id, type_=DoorLockingRuleType.CUSTOM, interval=10 + ).success_or_raise() + + # XXX: TODO: test the other examples? + + @responses.activate + def test_fetch_door_locking_rule(self) -> None: + """7.11 Fetch Door Locking Rule""" + door_id = DoorId("e4978b83-203d-4015-97df-b86efc91cb0c") + responses.get( + f"https://{self.host}/api/v1/developer/doors/{door_id}/lock_rule", + match=[matchers.header_matcher(self.common_headers)], + # Keep it locked + json={ + "code": "SUCCESS", + "data": {"ended_time": 3602128309, "type": "keep_lock"}, + "msg": "success", + }, + ) + + resp = self.client.fetch_door_locking_rule(door_id=door_id).success_or_raise() + # XXX: TODO: test the other examples? + + @responses.activate + def test_set_door_emergency_status(self) -> None: + """7.12 Set Door Emergency Status""" + responses.put( + f"https://{self.host}/api/v1/developer/doors/settings/emergency", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher({"lockdown": True, "evacuation": False}), + ], + json={"code": "SUCCESS", "data": "success", "msg": "success"}, + ) + + # Keep it locked + resp = self.client.set_door_emergency_status( + lockdown=True, evacuation=False + ).success_or_raise() + # XXX: TODO: test the other examples? + + @responses.activate + def test_fetch_door_emergency_status(self) -> None: + """7.13 Fetch Door Emergency Status""" + responses.get( + f"https://{self.host}/api/v1/developer/doors/settings/emergency", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": {"evacuation": True, "lockdown": False}, + "msg": "success", + }, + ) + + resp = self.client.fetch_door_emergency_status().success_or_raise() diff --git a/tests/test_system_log.py b/tests/test_system_log.py new file mode 100644 index 0000000..8a20664 --- /dev/null +++ b/tests/test_system_log.py @@ -0,0 +1,118 @@ +import datetime + +import responses +from responses import matchers + +from unifi_access.schemas import SystemLogTopic, UserId + +from .base import UnifiAccessTests + + +class SystemLogTests(UnifiAccessTests): + @responses.activate + def test_fetch_system_logs(self) -> None: + """9.2 Fetch System Logs""" + responses.post( + f"https://{self.host}/api/v1/developer/system/logs", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"page_size": 25, "page_num": 1}), + matchers.json_params_matcher( + { + "topic": "door_openings", + "since": 1690770546, + "until": 1690771546, + "actor_id": "3e1f196e-c97b-4748-aecb-eab5e9c251b2", + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "hits": [ + { + "@timestamp": "2023-07-11T12:11:27Z", + "_id": "", + "_source": { + "actor": { + "alternate_id": "", + "alternate_name": "", + "display_name": "N/A", + "id": "", + "type": "user", + }, + "authentication": { + "credential_provider": "NFC", + "issuer": "6FC02554", + }, + "event": { + "display_message": "Access Denied / Unknown (NFC)", + "published": 1689077487000, + "reason": "", + "result": "BLOCKED", + "type": "access.door.unlock", + # NOTE: missing from example, but present in live api + "log_key": "", + }, + "target": [ + { + "alternate_id": "", + "alternate_name": "", + "display_name": "UA-HUB-3855", + "id": "7483c2773855", + "type": "UAH", + } + ], + }, + "tag": "access", + } + ] + }, + # NOTE: example missing `msg` and `pagination`, had `page` and `total` instead + "msg": "succ", + "pagination": {"page_num": 1, "page_size": 10000, "total": 1}, + }, + ) + + resp = self.client.fetch_system_logs( + page_num=1, + page_size=25, + topic=SystemLogTopic.DOOR_OPENINGS, + since=datetime.datetime.fromtimestamp(1690770546, tz=datetime.UTC), + until=datetime.datetime.fromtimestamp(1690771546, tz=datetime.UTC), + actor_id=UserId("3e1f196e-c97b-4748-aecb-eab5e9c251b2"), + ).success_or_raise() + + @responses.activate + def test_export_system_logs(self) -> None: + """9.3 Export System Logs""" + responses.post( + f"https://{self.host}/api/v1/developer/system/logs/export", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "topic": "door_openings", + "since": 1690770546, + "until": 1690771546, + "timezone": "America/New_York", + "actor_id": "3e1f196e-c97b-4748-aecb-eab5e9c251b2", + } + ), + ], + # NOTE: example not provided by API + # TODO: what does the API do on errors? + body=b"time,actor.alternate_id,actor.alternate_name,actor.display_name,actor.id,actor.type,authentication.credential_provider,authentication.issuer,event.display_message,event.published,event.reason,event.result,event.type,target1.alternate_id,target1.alternate_name,target1.display_name,target1.id,target1.type,target2.alternate_id,target2.alternate_name,target2.display_name,target2.id,target2.type,target3.alternate_id,target3.alternate_name,target3.display_name,target3.id,target3.type,target4.alternate_id,target4.alternate_name,target4.display_name,target4.id,target4.type,target5.alternate_id,target5.alternate_name,target5.display_name,target5.id,target5.type\n", + ) + + resp = self.client.export_system_logs( + topic=SystemLogTopic.DOOR_OPENINGS, + since=datetime.datetime.fromtimestamp(1690770546, tz=datetime.UTC), + until=datetime.datetime.fromtimestamp(1690771546, tz=datetime.UTC), + timezone="America/New_York", + actor_id=UserId("3e1f196e-c97b-4748-aecb-eab5e9c251b2"), + ) + + # TODO: + # - 9.4 Fetch Resources in System Logs + # - 9.5 Fetch Static Resources in System Logs diff --git a/tests/test_unifi_identity.py b/tests/test_unifi_identity.py new file mode 100644 index 0000000..075510d --- /dev/null +++ b/tests/test_unifi_identity.py @@ -0,0 +1,249 @@ +import responses +from responses import matchers + +from unifi_access.schemas import ( + IdentityInvitationUser, + IdentityResourceType, + UserGroupId, + UserId, +) +from unifi_access.schemas._base import IdentityResourceId + +from .base import UnifiAccessTests + + +class UnifiIdentityTests(UnifiAccessTests): + @responses.activate + def test_send_unifi_identity_invitations(self) -> None: + """10.1 Send UniFi Identity Invitations""" + responses.post( + f"https://{self.host}/api/v1/developer/users/identity/invitations", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + [ + { + "user_id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + "email": "example@*.com", + } + ] + ), + ], + json={"code": "SUCCESS", "data": [], "msg": "success"}, + ) + resp = self.client.send_unifi_identity_invitations( + [ + IdentityInvitationUser( + user_id=UserId("e0051e08-c4d5-43db-87c8-a9b19cb66513"), + email="example@*.com", + ) + ] + ).success_or_raise() + + @responses.activate + def test_send_unifi_identity_invitations_email_failure(self) -> None: + """10.1 Send UniFi Identity Invitations""" + responses.post( + f"https://{self.host}/api/v1/developer/users/identity/invitations", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + [ + { + "user_id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + "email": "example@*.com", + } + ] + ), + ], + json={ + "code": "SUCCESS", + "data": [ + { + "error_code": "", + "error_msg": "invalid email", + "user_email": "example@*.com", + "user_id": "e0051e08-c4d5-43db-87c8-a9b19cb66513", + } + ], + "msg": "success", + }, + ) + # XXX: not sure what behavior here should be... + + resp = self.client.send_unifi_identity_invitations( + [ + IdentityInvitationUser( + user_id=UserId("e0051e08-c4d5-43db-87c8-a9b19cb66513"), + email="example@*.com", + ) + ] + ).success_or_raise() + + @responses.activate + def test_fetch_available_resources(self) -> None: + """10.2 Fetch Available Resources""" + responses.get( + f"https://{self.host}/api/v1/developer/users/identity/assignments", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"resource_type": "ev_station,wifi,vpn"}), + ], + json={ + "code": "SUCCESS", + "data": { + "ev_station": [], + "vpn": [ + { + "deleted": False, + "id": "65dff9a9c188cb71cfac8e9d", + "metadata": None, + "name": "UDM Pro", + "short_name": "", + } + ], + "wifi": [ + { + "deleted": False, + "id": "65dff9a8c188cb71cfac8e9a", + "metadata": None, + "name": "UniFi Identity", + "short_name": "", + } + ], + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_available_resources( + resource_type=[ + IdentityResourceType.EV_STATION, + IdentityResourceType.WIFI, + IdentityResourceType.VPN, + ] + ).success_or_raise() + + @responses.activate + def test_assign_resources_to_user(self) -> None: + """10.3 Assign Resources to Users""" + user_id = UserId("b602879b-b857-400b-970b-336d4cb881ad") + responses.post( + f"https://{self.host}/api/v1/developer/users/{user_id}/identity/assignments", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "resource_type": "wifi", + "resource_ids": ["65dff9a8c188cb71cfac8e9a"], + } + ), + ], + json={"code": "SUCCESS", "data": None, "msg": "success"}, + ) + + resp = self.client.assign_resources_to_user( + user_id=user_id, + resource_type=IdentityResourceType.WIFI, + resource_ids=[IdentityResourceId("65dff9a8c188cb71cfac8e9a")], + ).success_or_raise() + + @responses.activate + def test_fetch_resources_assigned_to_user(self) -> None: + """10.4 Fetch Resources Assigned to Users""" + user_id = UserId("b602879b-b857-400b-970b-336d4cb881ad") + responses.get( + f"https://{self.host}/api/v1/developer/users/{user_id}/identity/assignments", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "ev_station": [], + "vpn": [ + { + "deleted": False, + "id": "65dff9a9c188cb71cfac8e9d", + "metadata": {"has_ip": True}, + "name": "UDM Pro", + "short_name": "", + } + ], + "wifi": [ + { + "deleted": False, + "id": "65dff9a8c188cb71cfac8e9a", + "metadata": None, + "name": "UniFi Identity", + "short_name": "", + } + ], + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_resources_assigned_to_user( + user_id=user_id + ).success_or_raise() + + @responses.activate + def test_assign_resources_to_user_group(self) -> None: + """10.5 Assign Resources to User Groups""" + user_group_id = UserGroupId("7476c839-8e10-472e-894f-c5b8254c35b5") + responses.post( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/identity/assignments", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "resource_type": "wifi", + "resource_ids": ["65dff9a8c188cb71cfac8e9a"], + } + ), + ], + json={"code": "SUCCESS", "data": None, "msg": "success"}, + ) + + resp = self.client.assign_resources_to_user_group( + user_group_id=user_group_id, + resource_type=IdentityResourceType.WIFI, + resource_ids=[IdentityResourceId("65dff9a8c188cb71cfac8e9a")], + ).success_or_raise() + + @responses.activate + def test_fetch_resources_assigned_to_user_group(self) -> None: + """10.6 Fetch Resources Assigned to User Groups""" + user_group_id = UserGroupId("b602879b-b857-400b-970b-336d4cb881ad") + responses.get( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/identity/assignments", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "ev_station": [], + "vpn": [ + { + "deleted": False, + "id": "65dff9a9c188cb71cfac8e9d", + "metadata": {"has_ip": True}, + "name": "UDM Pro", + "short_name": "", + } + ], + "wifi": [ + { + "deleted": False, + "id": "65dff9a8c188cb71cfac8e9a", + "metadata": None, + "name": "UniFi Identity", + "short_name": "", + } + ], + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_resources_assigned_to_user_group( + user_group_id=user_group_id + ).success_or_raise() diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..297c27f --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,720 @@ +import datetime + +import responses +from responses import matchers + +from unifi_access.schemas import ( + AccessPolicyId, + NfcCardToken, + UserGroupId, + UserId, + UserStatus, +) + +from .base import UnifiAccessTests + + +class UserTests(UnifiAccessTests): + @responses.activate + def test_register_user(self) -> None: + """3.2 User Registration""" + responses.post( + f"https://{self.host}/api/v1/developer/users", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "first_name": "First Name", + "last_name": "Last Name", + "employee_number": "100000", + "onboard_time": 1689150139, + "user_email": "example@*.com", + } + ), + ], + json={ + "code": "SUCCESS", + "msg": "success", + "data": { + "id": "37f2b996-c2c5-487b-aa22-8b453ff14a4b", + "first_name": "First Name", + "last_name": "Last Name", + "employee_number": "100000", + "onboard_time": 1689150139, + "user_email": "example@*.com", + # NOTE: following fields missing from example, but present in real responses + "alias": "", + "avatar_relative_path": "", + "email": "", + "email_status": "UNVERIFIED", + "full_name": "First Name Last Name", + "phone": "", + "status": "ACTIVE", + "username": "", + }, + }, + ) + + resp = self.client.register_user( + first_name="First Name", + last_name="Last Name", + employee_number="100000", + onboard_time=datetime.datetime.fromtimestamp(1689150139, tz=datetime.UTC), + user_email="example@*.com", + ).success_or_raise() + assert resp.data.id == "37f2b996-c2c5-487b-aa22-8b453ff14a4b" + + @responses.activate + def test_update_user(self) -> None: + """3.3 Update User""" + user_id = UserId("37f2b996-c2c5-487b-aa22-8b453ff14a4b") + responses.put( + f"https://{self.host}/api/v1/developer/users/{user_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "first_name": "H", + "last_name": "L", + "employee_number": "", + "user_email": "example@*.com", + # XXX: TODO: this is not listed as a valid param in their docs... + # "pin_code": "", + "onboard_time": 1689150139, + "status": "ACTIVE", + } + ), + ], + json={"code": "SUCCESS", "msg": "success", "data": None}, + ) + + resp = self.client.update_user( + user_id=user_id, + first_name="H", + last_name="L", + employee_number="", + onboard_time=datetime.datetime.fromtimestamp(1689150139, tz=datetime.UTC), + user_email="example@*.com", + status=UserStatus.ACTIVE, + ).success_or_raise() + assert resp.data is None + + @responses.activate + def test_fetch_user(self) -> None: + """3.4 Fetch User""" + user_id = UserId("37f2b996-c2c5-487b-aa22-8b453ff14a4b") + responses.get( + f"https://{self.host}/api/v1/developer/users/{user_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"expand[]": "access_policy"}), + ], + json={ + "code": "SUCCESS", + "data": { + "access_policies": [ + { + "id": "edbc80df-3698-49fd-8b53-f1867f104947", + "name": "test", + "resources": [ + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + ], + "schedule_id": "73facd6c-839e-4521-a4f4-c07e1d44e748", + } + ], + "access_policy_ids": ["edbc80df-3698-49fd-8b53-f1867f104947"], + "employee_number": "", + "first_name": "***", + "id": "37f2b996-c2c5-487b-aa22-8b453ff14a4b", + "last_name": "L", + "user_email": "example@*.com", + "nfc_cards": [ + { + "id": "100001", + "token": "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0", + "type": "ua_card", + } + ], + "onboard_time": 1689047588, + "pin_code": { + "token": "5f742ee4424e5a7dd265de3461009b9ebafa1fb9d6b15018842055cc0466ac56" + }, + "status": "ACTIVE", + # NOTE: following fields missing from example, but present in real responses + "alias": "", + "avatar_relative_path": "", + "email": "", + "email_status": "UNVERIFIED", + "full_name": "*** L", + "phone": "", + "username": "", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_user( + user_id=user_id, expand_access_policies=True + ).success_or_raise() + # TODO: verify correctness of data? + + @responses.activate + def test_fetch_all_users(self) -> None: + """3.5 Fetch All Users""" + responses.get( + f"https://{self.host}/api/v1/developer/users", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher( + {"expand[]": "access_policy", "page_num": 1, "page_size": 25} + ), + ], + json={ + "code": "SUCCESS", + "data": [ + { + "access_policies": [ + { + "id": "73f15cab-c725-4a76-a419-a4026d131e96", + "name": "Default Admin Policy", + "resources": [ + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + ], + "schedule_id": "73facd6c-839e-4521-a4f4-c07e1d44e748", + } + ], + "access_policy_ids": ["73f15cab-c725-4a76-a419-a4026d131e96"], + "employee_number": "", + "first_name": "UniFi", + "id": "83569f9b-0096-48ab-b2e4-5c9a598568a8", + "last_name": "User", + "user_email": "", + "nfc_cards": [], + "onboard_time": 0, + "pin_code": None, + "status": "ACTIVE", + # NOTE: following fields missing from example, but present in real responses + "alias": "", + "avatar_relative_path": "", + "email": "", + "email_status": "UNVERIFIED", + "full_name": "UniFi User", + "phone": "", + "username": "", + }, + { + "access_policies": [ + { + "id": "c1682fb8-ef6e-4fe2-aa8a-b6f29df753ff", + "name": "policy_1690272668035", + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + } + ], + "schedule_id": "0616ef06-b807-4372-9ae0-7a87e12e4019", + } + ], + "access_policy_ids": ["c1682fb8-ef6e-4fe2-aa8a-b6f29df753ff"], + "employee_number": "", + "first_name": "Ttttt", + "id": "3a3ba57a-796e-46e0-b8f3-478bb70a114d", + "last_name": "Tttt", + "nfc_cards": [], + "onboard_time": 1689048000, + "pin_code": None, + "status": "ACTIVE", + # NOTE: following fields missing from example, but present in real responses + "alias": "", + "avatar_relative_path": "", + "user_email": "", + "email": "", + "email_status": "UNVERIFIED", + "full_name": "Ttttt Tttt", + "phone": "", + "username": "", + }, + ], + "msg": "success", + "pagination": {"page_num": 1, "page_size": 97, "total": 97}, + }, + ) + + resp = self.client.fetch_all_users( + expand_access_policies=True, page_num=1, page_size=25 + ).success_or_raise() + assert resp.pagination + # TODO: verify correctness of data? + + @responses.activate + def test_assign_access_policy_to_user(self) -> None: + """3.6 Assign Access Policy to User""" + user_id = UserId("37f2b996-c2c5-487b-aa22-8b453ff14a4b") + responses.put( + f"https://{self.host}/api/v1/developer/users/{user_id}/access_policies", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "access_policy_ids": [ + "03895c7f-9f53-4334-812b-5db9c122c109", + "3b6bcb0c-7498-44cf-8615-00a96d824cbe", + ] + } + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + resp = self.client.assign_access_policy_to_user( + user_id=user_id, + access_policy_ids=[ + AccessPolicyId("03895c7f-9f53-4334-812b-5db9c122c109"), + AccessPolicyId("3b6bcb0c-7498-44cf-8615-00a96d824cbe"), + ], + ).success_or_raise() + assert resp.data is None + + @responses.activate + def test_assign_nfc_card_to_user(self) -> None: + """3.7 Assign NFC Card to User""" + user_id = UserId("17d2f099-99df-429b-becb-1399a6937e5a") + responses.put( + f"https://{self.host}/api/v1/developer/users/{user_id}/nfc_cards", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "token": "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0", + "force_add": True, + } + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + resp = self.client.assign_nfc_card_to_user( + user_id=user_id, + token=NfcCardToken( + "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0" + ), + force_add=True, + ).success_or_raise() + assert resp.data is None + + @responses.activate + def test_unassign_nfc_card_from_user(self) -> None: + """3.8 Unassign NFC Card from User""" + user_id = UserId("17d2f099-99df-429b-becb-1399a6937e5a") + responses.put( + f"https://{self.host}/api/v1/developer/users/{user_id}/nfc_cards/delete", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "token": "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0", + } + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + resp = self.client.unassign_nfc_card_from_user( + user_id=user_id, + token=NfcCardToken( + "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0" + ), + ).success_or_raise() + assert resp.data is None + + @responses.activate + def test_assign_pin_code_to_user(self) -> None: + """3.9 Assign PIN Code to User""" + user_id = UserId("17d2f099-99df-429b-becb-1399a6937e5a") + responses.put( + f"https://{self.host}/api/v1/developer/users/{user_id}/pin_codes", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher({"pin_code": "57301208"}), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + resp = self.client.assign_pin_code_to_user( + user_id=user_id, pin_code="57301208" + ).success_or_raise() + assert resp.data is None + + @responses.activate + def test_unassign_pin_code_from_user(self) -> None: + """3.10 Unassign PIN Code from User""" + user_id = UserId("17d2f099-99df-429b-becb-1399a6937e5a") + responses.delete( + f"https://{self.host}/api/v1/developer/users/{user_id}/pin_codes", + match=[ + matchers.header_matcher(self.common_headers), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + resp = self.client.unassign_pin_code_from_user( + user_id=user_id, + ).success_or_raise() + assert resp.data is None + + @responses.activate + def test_create_user_group(self) -> None: + """3.11 Create User Group""" + responses.post( + f"https://{self.host}/api/v1/developer/user_groups", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "Group Name", + "up_id": "013d05d3-7262-4908-ba69-badbbbf8f5a6", + } + ), + ], + json={ + "code": "SUCCESS", + # NOTE: completely missing from docs + "data": { + "full_name": "UniFi-CloudKey-Gen2-Plus / Group Name", + "id": "7a681dd8-4d38-4bbf-a061-2fd5d71db5c1", + "name": "Group Name", + "up_id": "bdec1374-9547-4541-9761-d9e66cb1c367", + "up_ids": ["bdec1374-9547-4541-9761-d9e66cb1c367"], + }, + "msg": "success", + }, + ) + + resp = self.client.create_user_group( + name="Group Name", up_id=UserGroupId("013d05d3-7262-4908-ba69-badbbbf8f5a6") + ).success_or_raise() + + @responses.activate + def test_fetch_all_user_groups(self) -> None: + """3.12 Fetch All User Groups""" + responses.get( + f"https://{self.host}/api/v1/developer/user_groups", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "full_name": "Group Name", + "id": "75011ee6-b7ab-4927-9d9f-dd08ef0a3199", + "name": "Group Name", + "up_id": "a27899fc-a2d1-4797-8d4d-86118f8555f3", + "up_ids": ["a27899fc-a2d1-4797-8d4d-86118f8555f3"], + } + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_user_groups().success_or_raise() + + @responses.activate + def test_fetch_user_group(self) -> None: + """3.13 Fetch User Group""" + user_group_id = UserGroupId("75011ee6-b7ab-4927-9d9f-dd08ef0a3199") + + responses.get( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": { + "full_name": "Group Name", + "id": "75011ee6-b7ab-4927-9d9f-dd08ef0a3199", + "name": "Group Name", + "up_id": "a27899fc-a2d1-4797-8d4d-86118f8555f3", + "up_ids": ["a27899fc-a2d1-4797-8d4d-86118f8555f3"], + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_user_group( + user_group_id=user_group_id + ).success_or_raise() + + @responses.activate + def test_update_user_group(self) -> None: + """3.14 Update User Group""" + user_group_id = UserGroupId("75011ee6-b7ab-4927-9d9f-dd08ef0a3199") + responses.put( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "name": "Group Name", + "up_id": "013d05d3-7262-4908-ba69-badbbbf8f5a6", + } + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.update_user_group( + user_group_id=user_group_id, + name="Group Name", + up_id=UserGroupId("013d05d3-7262-4908-ba69-badbbbf8f5a6"), + ).success_or_raise() + + @responses.activate + def test_delete_user_group(self) -> None: + """3.15 Delete User Group""" + user_group_id = UserGroupId("75011ee6-b7ab-4927-9d9f-dd08ef0a3199") + + responses.delete( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}", + match=[matchers.header_matcher(self.common_headers)], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.delete_user_group( + user_group_id=user_group_id + ).success_or_raise() + + @responses.activate + def test_assign_user_to_user_group(self) -> None: + """3.16 Assign User to User Group""" + user_group_id = UserGroupId("75011ee6-b7ab-4927-9d9f-dd08ef0a3199") + + responses.post( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/users", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + [ + "7c6e9102-acb7-4b89-8ed4-7561e6fb706c", + "fd63bc4c-52e0-4dbf-a699-e1233339c73b", + ] + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.assign_user_to_user_group( + user_group_id=user_group_id, + users=[ + UserId("7c6e9102-acb7-4b89-8ed4-7561e6fb706c"), + UserId("fd63bc4c-52e0-4dbf-a699-e1233339c73b"), + ], + ).success_or_raise() + + @responses.activate + def test_unassign_user_from_user_group(self) -> None: + """3.17 Unassign User from User Group""" + user_group_id = UserGroupId("75011ee6-b7ab-4927-9d9f-dd08ef0a3199") + + responses.post( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/users/delete", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + [ + "7c6e9102-acb7-4b89-8ed4-7561e6fb706c", + "fd63bc4c-52e0-4dbf-a699-e1233339c73b", + ] + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.unassign_user_from_user_group( + user_group_id=user_group_id, + users=[ + UserId("7c6e9102-acb7-4b89-8ed4-7561e6fb706c"), + UserId("fd63bc4c-52e0-4dbf-a699-e1233339c73b"), + ], + ).success_or_raise() + + @responses.activate + def test_fetch_users_in_a_user_group(self) -> None: + """3.18 Fetch Users in a User Group""" + user_group_id = UserGroupId("23676a54-382e-4121-aa80-878d2d9bacaa") + + responses.get( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/users", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "alias": "", + "avatar_relative_path": "", + "email": "*@*.com", + "email_status": "UNVERIFIED", + "employee_number": "1000000", + "first_name": "", + "full_name": "", + "id": "27aa91ac-2924-43d4-82e1-24b6a570d29e", + "last_name": "Chen", + "onboard_time": 1689150139, + "phone": "", + "status": "ACTIVE", + "user_email": "", + "username": "", + } + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_users_in_a_user_group( + user_group_id=user_group_id, + ).success_or_raise() + + @responses.activate + def test_fetch_all_users_in_a_user_group(self) -> None: + """3.19 Fetch All Users in a User Group""" + user_group_id = UserGroupId("23676a54-382e-4121-aa80-878d2d9bacaa") + + responses.get( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/users/all", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "alias": "", + "avatar_relative_path": "", + "email": "*@*.com", + "email_status": "UNVERIFIED", + "employee_number": "1000000", + "first_name": "", + "full_name": "", + "id": "27aa91ac-2924-43d4-82e1-24b6a570d29e", + "last_name": "Chen", + "onboard_time": 1689150139, + "phone": "", + "status": "ACTIVE", + "user_email": "", + "username": "", + } + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_all_users_in_a_user_group( + user_group_id=user_group_id, + ).success_or_raise() + + @responses.activate + def test_fetch_the_access_policies_assigned_to_a_user(self) -> None: + """3.20 Fetch the Access Policies Assigned to a User""" + user_id = UserId("27aa91ac-2924-43d4-82e1-24b6a570d29e") + + responses.get( + f"https://{self.host}/api/v1/developer/users/{user_id}/access_policies", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"only_user_policies": "false"}), + ], + json={ + "code": "SUCCESS", + "data": [ + { + "id": "89a4ca95-1502-4ae7-954f-d986b67afe5c", + "name": "Default Site Policy", + "resources": [ + { + "id": "fd2a06e2-81af-4cf4-9bd5-8bceb5e7b7d7", + "type": "door_group", + } + ], + "schedule_id": "6b79d12a-2a6e-4463-949c-f1a98fff40d2", + }, + { + "id": "bbe48a65-2ac1-4bf6-bd65-bc8f9ee7fb75", + "name": "Access Policy Name", + "resources": [], + "schedule_id": "f7414bcd-f0cc-4d3e-811a-b5ac75f7ddb8", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_the_access_policies_assigned_to_a_user( + user_id=user_id, only_user_policies=False + ).success_or_raise() + + @responses.activate + def test_assign_access_policy_to_user_group(self) -> None: + """3.21 Assign Access Policy to User Group""" + user_group_id = UserGroupId("23676a54-382e-4121-aa80-878d2d9bacaa") + + responses.put( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/access_policies", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + {"access_policy_ids": ["bbe48a65-2ac1-4bf6-bd65-bc8f9ee7fb75"]} + ), + ], + json={"code": "SUCCESS", "data": None, "msg": "success"}, + ) + + resp = self.client.assign_access_policy_to_user_group( + user_group_id=user_group_id, + access_policy_ids=[AccessPolicyId("bbe48a65-2ac1-4bf6-bd65-bc8f9ee7fb75")], + ).success_or_raise() + + @responses.activate + def test_fetch_the_access_policies_assigned_to_a_user_group(self) -> None: + """3.22 Fetch the Access Policies Assigned to a User Group""" + user_group_id = UserGroupId("23676a54-382e-4121-aa80-878d2d9bacaa") + + responses.get( + f"https://{self.host}/api/v1/developer/user_groups/{user_group_id}/access_policies", + match=[matchers.header_matcher(self.common_headers)], + json={ + "code": "SUCCESS", + "data": [ + { + "id": "89a4ca95-1502-4ae7-954f-d986b67afe5c", + "name": "Default Site Policy", + "resources": [ + { + "id": "fd2a06e2-81af-4cf4-9bd5-8bceb5e7b7d7", + "type": "door_group", + } + ], + "schedule_id": "6b79d12a-2a6e-4463-949c-f1a98fff40d2", + }, + { + "id": "bbe48a65-2ac1-4bf6-bd65-bc8f9ee7fb75", + "name": "Access Policy Name", + "resources": [], + "schedule_id": "f7414bcd-f0cc-4d3e-811a-b5ac75f7ddb8", + }, + ], + "msg": "success", + }, + ) + + resp = self.client.fetch_the_access_policies_assigned_to_a_user_group( + user_group_id=user_group_id, + ).success_or_raise() diff --git a/tests/test_visitor.py b/tests/test_visitor.py new file mode 100644 index 0000000..03ea1e3 --- /dev/null +++ b/tests/test_visitor.py @@ -0,0 +1,724 @@ +import datetime + +import responses +from responses import matchers + +from unifi_access.schemas import ( + DoorGroupId, + DoorGroupResource, + DoorId, + DoorResource, + FetchAllVisitorsExpansion, + NfcCardToken, + TimePeriod, + VisitorId, + VisitReason, + WeekSchedule, +) + +from .base import UnifiAccessTests + + +class VisitorTests(UnifiAccessTests): + @responses.activate + def test_create_visitor(self) -> None: + """4.2 Create Visitor""" + responses.post( + f"https://{self.host}/api/v1/developer/visitors", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "first_name": "H", + "last_name": "L", + "remarks": "", + "mobile_phone": "", + "email": "", + "visitor_company": "", + "start_time": 1688546460, + "end_time": 1788572799, + # NOTE: typo'ed in api docs to "Interviemw" + "visit_reason": "Interview", + "week_schedule": { + "sunday": [], + "monday": [], + "tuesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "wednesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "thursday": [ + {"start_time": "11:00:00", "end_time": "17:00:59"} + ], + "friday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "saturday": [], + }, + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "first_name": "H", + "id": "fbe8d920-47d3-4cfd-bda7-bf4b0e26f73c", + "last_name": "L", + "nfc_cards": [], + "resources": [ + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "name": "Test Group", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "name": "UNVR", + "type": "door_group", + }, + { + "id": "369215b0-cabe-49b6-aeaa-e0b7ec6424d5", + "name": "visitor-1691671529285", + "type": "door_group", + }, + ], + "schedule": { + "id": "1fb849bb-e7e5-4516-8dd9-b78094a6708a", + "is_default": False, + "name": "schedule-1691671529237", + "type": "access", + "weekly": { + "friday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + "monday": [], + "saturday": [], + "sunday": [], + "thursday": [ + {"end_time": "17:00:59", "start_time": "11:00:00"} + ], + "tuesday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + "wednesday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + }, + }, + "schedule_id": "1fb849bb-e7e5-4516-8dd9-b78094a6708a", + "status": "ACTIVE", + }, + "msg": "success", + }, + ) + + resp = self.client.create_visitor( + first_name="H", + last_name="L", + remarks="", + mobile_phone="", + email="", + visitor_company="", + start_time=datetime.datetime.fromtimestamp(1688546460, tz=datetime.UTC), + end_time=datetime.datetime.fromtimestamp(1788572799, tz=datetime.UTC), + visit_reason=VisitReason.INTERVIEW, + week_schedule=WeekSchedule( + sunday=[], + monday=[], + tuesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + wednesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + thursday=[ + TimePeriod( + start_time=datetime.time(11, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + friday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + saturday=[], + ), + resources=[ + DoorResource( + id=DoorId("6ff875d2-af87-470b-9cb5-774c6596afc8"), + ), + DoorGroupResource( + id=DoorGroupId("5c496423-6d25-4e4f-8cdf-95ad5135188a"), + ), + DoorGroupResource( + id=DoorGroupId("d5573467-d6b3-4e8f-8e48-8a322b91664a") + ), + ], + ).success_or_raise() + assert resp.data.id == "fbe8d920-47d3-4cfd-bda7-bf4b0e26f73c" + + @responses.activate + def test_fetch_visitor(self) -> None: + """4.3 Fetch Visitor""" + visitor_id = VisitorId("76794bd8-98c0-49b6-9230-ba8c5812cf29") + responses.get( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}", + match=[ + matchers.header_matcher(self.common_headers), + ], + json={ + "code": "SUCCESS", + "data": { + "first_name": "Hong+243", + "id": "3566867c-fa04-4752-98f6-43cf9a342d4a", + "last_name": "Lu", + "nfc_cards": [ + { + "id": "100001", + "token": "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0", + "type": "ua_card", + } + ], + "pin_code": { + "token": "bc3e3135013e2dcae119390b7897166e8cec3bcf5becb6b05578ab67634559ed" + }, + "resources": [ + { + "id": "fd293ecb-98d2-425b-a020-cb9365ea48b3", + "name": "visitor-1690337152955", + "type": "door_group", + } + ], + "schedule": { + "id": "6ccf9e1e-b174-476d-b2fe-96817c780fbf", + "is_default": False, + "name": "visitor-1690337152955", + "type": "access", + "weekly": None, + }, + "schedule_id": "6ccf9e1e-b174-476d-b2fe-96817c780fbf", + "status": "VISITED", + }, + "msg": "success", + }, + ) + + resp = self.client.fetch_visitor(visitor_id=visitor_id).success_or_raise() + # TODO: verify correctness of data? + + @responses.activate + def test_fetch_all_visitors(self) -> None: + """4.4 Fetch All Visitors""" + responses.get( + f"https://{self.host}/api/v1/developer/visitors", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"page_num": 1, "page_size": 25}), + ], + # NOTE: no example data provided in api docs, so this was + # retrieved from a running instance + json={ + "code": "SUCCESS", + "data": [ + { + "avatar": "", + "email": "", + "end_time": 1731880901, + "first_name": "Test", + "id": "faaffd2e-b555-4991-810f-c18b36407c55", + "inviter_id": "", + "inviter_name": "", + "last_name": "Visitor", + "location_id": "", + "mobile_phone": "", + "nfc_cards": [], + "remarks": "", + "resources": [], + "schedule": { + "holiday_group": None, + "holiday_group_id": "", + "holiday_schedule": [], + "id": "", + "is_default": False, + "name": "", + "type": "", + "weekly": None, + }, + "schedule_id": "", + "start_time": 1731794501, + "status": "UPCOMING", + "visit_reason": "Business", + "visitor_company": "", + } + ], + "msg": "succ", + "pagination": {"page_num": 1, "page_size": 1, "total": 1}, + }, + ) + + resp = self.client.fetch_all_visitors( + page_num=1, page_size=25 + ).success_or_raise() + assert resp.pagination + # TODO: verify correctness of data? + + @responses.activate + def test_fetch_all_visitors_by_keyword(self) -> None: + """4.4 Fetch All Visitors""" + responses.get( + f"https://{self.host}/api/v1/developer/visitors", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"keyword": "H"}), + ], + # NOTE: no example data provided in api docs, so this was + # retrieved from a running instance + json={ + "code": "SUCCESS", + "data": [ + { + "avatar": "", + "email": "", + "end_time": 1731880901, + "first_name": "Test H", + "id": "faaffd2e-b555-4991-810f-c18b36407c55", + "inviter_id": "", + "inviter_name": "", + "last_name": "Visitor", + "location_id": "", + "mobile_phone": "", + "nfc_cards": [], + "remarks": "", + "resources": [], + "schedule": { + "holiday_group": None, + "holiday_group_id": "", + "holiday_schedule": [], + "id": "", + "is_default": False, + "name": "", + "type": "", + "weekly": None, + }, + "schedule_id": "", + "start_time": 1731794501, + "status": "UPCOMING", + "visit_reason": "Business", + "visitor_company": "", + } + ], + "msg": "succ", + "pagination": {"page_num": 1, "page_size": 1, "total": 1}, + }, + ) + + resp = self.client.fetch_all_visitors(keyword="H").success_or_raise() + assert resp.pagination + # TODO: verify correctness of data? + + @responses.activate + def test_fetch_all_visitors_with_expand(self) -> None: + """4.4 Fetch All Visitors""" + responses.get( + f"https://{self.host}/api/v1/developer/visitors", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher( + { + "expand[]": [ + "access_policy", + "resource", + "schedule", + "nfc_card", + "pin_code", + ] + } + ), + ], + # NOTE: no example data provided in api docs, so this was + # retrieved from a running instance + json={ + "code": "SUCCESS", + "data": [ + { + "avatar": "", + "email": "", + "end_time": 1731880901, + "first_name": "Test", + "id": "faaffd2e-b555-4991-810f-c18b36407c55", + "inviter_id": "", + "inviter_name": "", + "last_name": "Visitor", + "location_id": "", + "mobile_phone": "", + "nfc_cards": [], + "remarks": "", + "resources": [], + "schedule": { + "holiday_group": None, + "holiday_group_id": "", + "holiday_schedule": [], + "id": "", + "is_default": False, + "name": "", + "type": "", + "weekly": None, + }, + "schedule_id": "", + "start_time": 1731794501, + "status": "UPCOMING", + "visit_reason": "Business", + "visitor_company": "", + } + ], + "msg": "succ", + "pagination": {"page_num": 1, "page_size": 1, "total": 1}, + }, + ) + + resp = self.client.fetch_all_visitors( + expand=[ + FetchAllVisitorsExpansion.ACCESS_POLICY, + FetchAllVisitorsExpansion.RESOURCE, + FetchAllVisitorsExpansion.SCHEDULE, + FetchAllVisitorsExpansion.NFC_CARD, + FetchAllVisitorsExpansion.PIN_CODE, + ] + ).success_or_raise() + assert resp.pagination + # TODO: verify correctness of data? + + # NOTE: not taken from API example + @responses.activate + def test_fetch_all_visitors_without_expand(self) -> None: + """4.4 Fetch All Visitors""" + responses.get( + f"https://{self.host}/api/v1/developer/visitors", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"expand[]": "none"}), + ], + # NOTE: retrieved from a running instance + json={ + "code": "SUCCESS", + "data": [ + { + "avatar": "", + "email": "", + "end_time": 1731907089, + "first_name": "Test", + "id": "173c4cb9-e174-4a83-89fa-01ba8f25362f", + "inviter_id": "", + "inviter_name": "", + "last_name": "Visitor", + "location_id": "", + "mobile_phone": "", + "nfc_cards": [], + "pin_code": {}, + "remarks": "", + "resources": [], + "schedule_id": "", + "start_time": 1731820689, + "status": "UPCOMING", + "visit_reason": "Business", + "visitor_company": "", + }, + ], + "msg": "succ", + "pagination": {"page_num": 1, "page_size": 1, "total": 1}, + }, + ) + + resp = self.client.fetch_all_visitors( + expand=[FetchAllVisitorsExpansion.NONE] + ).success_or_raise() + assert resp.pagination + # TODO: verify correctness of data? + + @responses.activate + def test_update_visitor(self) -> None: + """4.5 Update Visitor""" + visitor_id = VisitorId("37f2b996-c2c5-487b-aa22-8b453ff14a4b") + responses.put( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "first_name": "Test", + "last_name": "L", + "remarks": "", + "mobile_phone": "", + "email": "", + "visitor_company": "", + "start_time": 1688546460, + "end_time": 1788572799, + # NOTE: typo'ed in api docs to "Interviemw" + "visit_reason": "Interview", + "week_schedule": { + "sunday": [], + "monday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "tuesday": [], + "wednesday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "thursday": [ + {"start_time": "11:00:00", "end_time": "18:00:59"} + ], + "friday": [ + {"start_time": "10:00:00", "end_time": "17:00:59"} + ], + "saturday": [], + }, + "resources": [ + { + "id": "6ff875d2-af87-470b-9cb5-774c6596afc8", + "type": "door", + }, + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "type": "door_group", + }, + ], + } + ), + ], + json={ + "code": "SUCCESS", + "data": { + "first_name": "H", + "id": "8564ce90-76ba-445f-b78b-6cca39af0130", + "last_name": "L", + "nfc_cards": [], + "pin_code": None, + "resources": [ + { + "id": "5c496423-6d25-4e4f-8cdf-95ad5135188a", + "name": "Door-Group-1", + "type": "door_group", + }, + { + "id": "d5573467-d6b3-4e8f-8e48-8a322b91664a", + "name": "UNVR", + "type": "door_group", + }, + { + "id": "e311ca94-172c-49fe-9c91-49bd8ecef845", + "name": "visitor-1691646856144", + "type": "door_group", + }, + ], + "schedule": { + "id": "c03bf601-0b90-4341-bce4-6061931e9f4e", + "is_default": False, + "name": "visitor-1691646856097", + "type": "access", + "weekly": { + "friday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + "monday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + "saturday": [], + "sunday": [], + "thursday": [ + {"end_time": "18:00:59", "start_time": "11:00:00"} + ], + "tuesday": [], + "wednesday": [ + {"end_time": "17:00:59", "start_time": "10:00:00"} + ], + }, + }, + "schedule_id": "c03bf601-0b90-4341-bce4-6061931e9f4e", + "status": "ACTIVE", + }, + "msg": "success", + }, + ) + + resp = self.client.update_visitor( + visitor_id=visitor_id, + first_name="Test", + last_name="L", + remarks="", + mobile_phone="", + email="", + visitor_company="", + start_time=datetime.datetime.fromtimestamp(1688546460, tz=datetime.UTC), + end_time=datetime.datetime.fromtimestamp(1788572799, tz=datetime.UTC), + visit_reason=VisitReason.INTERVIEW, + week_schedule=WeekSchedule( + sunday=[], + monday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + tuesday=[], + wednesday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + thursday=[ + TimePeriod( + start_time=datetime.time(11, 0, 0), + end_time=datetime.time(18, 0, 59), + ) + ], + friday=[ + TimePeriod( + start_time=datetime.time(10, 0, 0), + end_time=datetime.time(17, 0, 59), + ) + ], + saturday=[], + ), + resources=[ + DoorResource( + id=DoorId("6ff875d2-af87-470b-9cb5-774c6596afc8"), + ), + DoorGroupResource( + id=DoorGroupId("5c496423-6d25-4e4f-8cdf-95ad5135188a"), + ), + DoorGroupResource( + id=DoorGroupId("d5573467-d6b3-4e8f-8e48-8a322b91664a") + ), + ], + ).success_or_raise() + + @responses.activate + def test_delete_visitor(self) -> None: + """4.6 Delete Visitor""" + visitor_id = VisitorId("c81dfee6-5970-4938-bd30-40820e16ea01") + responses.delete( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}", + match=[ + matchers.header_matcher(self.common_headers), + matchers.query_param_matcher({"is_force": "true"}), + ], + json={"code": "SUCCESS", "data": None, "msg": "success"}, + ) + + resp = self.client.delete_visitor( + visitor_id=visitor_id, + is_force=True, + ).success_or_raise() + # TODO: verify correctness of data? + + @responses.activate + def test_assign_nfc_card_to_visitor(self) -> None: + """4.7 Assign NFC Card To Visitor""" + visitor_id = VisitorId("60b5c15e-9aff-4fc8-9547-d21d2e39c1ff") + responses.put( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}/nfc_cards", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "token": "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0", + "force_add": True, + } + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.assign_nfc_card_to_visitor( + visitor_id=visitor_id, + token=NfcCardToken( + "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0" + ), + force_add=True, + ).success_or_raise() + + @responses.activate + def test_unassign_nfc_card_from_visitor(self) -> None: + """4.8 Unassign NFC Card From Visitor""" + visitor_id = VisitorId("60b5c15e-9aff-4fc8-9547-d21d2e39c1ff") + responses.put( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}/nfc_cards/delete", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher( + { + "token": "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0" + } + ), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.unassign_nfc_card_from_visitor( + visitor_id=visitor_id, + token=NfcCardToken( + "d27822fc682b478dc637c6db01813e465174df6e54ca515d8427db623cfda1d0" + ), + ).success_or_raise() + + @responses.activate + def test_assign_pin_code_to_visitor(self) -> None: + """4.9 Assign Pin Code To Visitor""" + visitor_id = VisitorId("17d2f099-99df-429b-becb-1399a6937e5a") + responses.put( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}/pin_codes", + match=[ + matchers.header_matcher(self.common_headers), + matchers.json_params_matcher({"pin_code": "57301208"}), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.assign_pin_code_to_visitor( + visitor_id=visitor_id, + pin_code="57301208", + ).success_or_raise() + + @responses.activate + def test_unassign_pin_code_from_visitor(self) -> None: + """4.10 Unassign Pin Code From Visitor""" + visitor_id = VisitorId("17d2f099-99df-429b-becb-1399a6937e5a") + responses.delete( + f"https://{self.host}/api/v1/developer/visitors/{visitor_id}/pin_codes", + match=[ + matchers.header_matcher(self.common_headers), + ], + json={"code": "SUCCESS", "msg": "success"}, + ) + + resp = self.client.unassign_pin_code_from_visitor( + visitor_id=visitor_id + ).success_or_raise() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..00c8348 --- /dev/null +++ b/uv.lock @@ -0,0 +1,802 @@ +version = 1 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version < '3.13'", + "python_full_version >= '3.13'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "babel" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, + { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, + { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, + { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, + { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, + { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, + { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, + { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, + { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, + { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, + { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, + { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, + { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, + { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + +[[package]] +name = "griffe" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/c9/8167810358ca129839156dc002526e7398b5fad4a9d7b6e88b875e802d0d/griffe-1.5.1.tar.gz", hash = "sha256:72964f93e08c553257706d6cd2c42d1c172213feb48b2be386f243380b405d4b", size = 384113 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/00/e693a155da0a2a72fd2df75b8fe338146cae59d590ad6f56800adde90cb5/griffe-1.5.1-py3-none-any.whl", hash = "sha256:ad6a7980f8c424c9102160aafa3bcdf799df0e75f7829d75af9ee5aef656f860", size = 127132 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, +] + +[[package]] +name = "markdown" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/ae/0f1154c614d6a8b8a36fff084e5b82af3a15f7d2060cf0dcdb1c53297a71/mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f", size = 40262 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/26/4d39d52ea2219604053a4d05b98e90d6a335511cc01806436ec4886b1028/mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f", size = 16522 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.5.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/56/182d8121db9ab553cdf9bc58d5972b89833f60b63272f693c1f2b849b640/mkdocs_material-9.5.44.tar.gz", hash = "sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0", size = 3964306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/eb/a801d00e0e210d82184aacce596906ec065422c78a7319244ba0771c4ded/mkdocs_material-9.5.44-py3-none-any.whl", hash = "sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca", size = 8674509 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "platformdirs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/5a/5de70538c2cefae7ac3a15b5601e306ef3717290cb2aab11d51cbbc2d1c0/mkdocstrings-0.27.0.tar.gz", hash = "sha256:16adca6d6b0a1f9e0c07ff0b02ced8e16f228a9d65a37c063ec4c14d7b76a657", size = 94830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/10/4c27c3063c2b3681a4b7942f8dbdeb4fa34fecb2c19b594e7345ebf4f86f/mkdocstrings-0.27.0-py3-none-any.whl", hash = "sha256:6ceaa7ea830770959b55a16203ac63da24badd71325b96af950e59fd37366332", size = 30658 }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/ec/cb6debe2db77f1ef42b25b21d93b5021474de3037cd82385e586aee72545/mkdocstrings_python-1.12.2.tar.gz", hash = "sha256:7a1760941c0b52a2cd87b960a9e21112ffe52e7df9d0b9583d04d47ed2e186f3", size = 168207 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/c1/ac524e1026d9580cbc654b5d19f5843c8b364a66d30f956372cd09fd2f92/mkdocstrings_python-1.12.2-py3-none-any.whl", hash = "sha256:7f7d40d6db3cb1f5d19dbcd80e3efe4d0ba32b073272c0c0de9de2e604eda62a", size = 111759 }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 }, +] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 }, + { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 }, + { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 }, + { url = "https://files.pythonhosted.org/packages/a9/8f/89c1405176903e567c5f99ec53387449e62f1121894aa9fc2c4fdc51a59b/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607", size = 1805307 }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1a194447d0da1ef492e3470680c66048fef56fc1f1a25cafbea4bc1d1c48/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", size = 2000663 }, + { url = "https://files.pythonhosted.org/packages/13/a5/1df8541651de4455e7d587cf556201b4f7997191e110bca3b589218745a5/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", size = 2655941 }, + { url = "https://files.pythonhosted.org/packages/44/31/a3899b5ce02c4316865e390107f145089876dff7e1dfc770a231d836aed8/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", size = 2052105 }, + { url = "https://files.pythonhosted.org/packages/1b/aa/98e190f8745d5ec831f6d5449344c48c0627ac5fed4e5340a44b74878f8e/pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", size = 1919967 }, + { url = "https://files.pythonhosted.org/packages/ae/35/b6e00b6abb2acfee3e8f85558c02a0822e9a8b2f2d812ea8b9079b118ba0/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", size = 1964291 }, + { url = "https://files.pythonhosted.org/packages/13/46/7bee6d32b69191cd649bbbd2361af79c472d72cb29bb2024f0b6e350ba06/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", size = 2109666 }, + { url = "https://files.pythonhosted.org/packages/39/ef/7b34f1b122a81b68ed0a7d0e564da9ccdc9a2924c8d6c6b5b11fa3a56970/pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", size = 1732940 }, + { url = "https://files.pythonhosted.org/packages/2f/76/37b7e76c645843ff46c1d73e046207311ef298d3f7b2f7d8f6ac60113071/pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", size = 1916804 }, + { url = "https://files.pythonhosted.org/packages/74/7b/8e315f80666194b354966ec84b7d567da77ad927ed6323db4006cf915f3f/pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", size = 1856459 }, + { url = "https://files.pythonhosted.org/packages/14/de/866bdce10ed808323d437612aca1ec9971b981e1c52e5e42ad9b8e17a6f6/pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", size = 1770007 }, + { url = "https://files.pythonhosted.org/packages/dc/69/8edd5c3cd48bb833a3f7ef9b81d7666ccddd3c9a635225214e044b6e8281/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", size = 1790245 }, + { url = "https://files.pythonhosted.org/packages/80/33/9c24334e3af796ce80d2274940aae38dd4e5676298b4398eff103a79e02d/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", size = 1801260 }, + { url = "https://files.pythonhosted.org/packages/a5/6f/e9567fd90104b79b101ca9d120219644d3314962caa7948dd8b965e9f83e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", size = 1996872 }, + { url = "https://files.pythonhosted.org/packages/2d/ad/b5f0fe9e6cfee915dd144edbd10b6e9c9c9c9d7a56b69256d124b8ac682e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", size = 2661617 }, + { url = "https://files.pythonhosted.org/packages/06/c8/7d4b708f8d05a5cbfda3243aad468052c6e99de7d0937c9146c24d9f12e9/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", size = 2071831 }, + { url = "https://files.pythonhosted.org/packages/89/4d/3079d00c47f22c9a9a8220db088b309ad6e600a73d7a69473e3a8e5e3ea3/pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", size = 1917453 }, + { url = "https://files.pythonhosted.org/packages/e9/88/9df5b7ce880a4703fcc2d76c8c2d8eb9f861f79d0c56f4b8f5f2607ccec8/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", size = 1968793 }, + { url = "https://files.pythonhosted.org/packages/e3/b9/41f7efe80f6ce2ed3ee3c2dcfe10ab7adc1172f778cc9659509a79518c43/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", size = 2116872 }, + { url = "https://files.pythonhosted.org/packages/63/08/b59b7a92e03dd25554b0436554bf23e7c29abae7cce4b1c459cd92746811/pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", size = 1738535 }, + { url = "https://files.pythonhosted.org/packages/88/8d/479293e4d39ab409747926eec4329de5b7129beaedc3786eca070605d07f/pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", size = 1917992 }, + { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 }, + { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 }, + { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 }, + { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 }, + { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 }, + { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 }, + { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 }, + { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 }, + { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 }, + { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, + { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, + { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/0b/32f05854cfd432e9286bb41a870e0d1a926b72df5f5cdb6dec962b2e369e/pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7", size = 840790 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/32/95a164ddf533bd676cbbe878e36e89b4ade3efde8dd61d0148c90cbbe57e/pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77", size = 263448 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, +] + +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/58/7e4d9493a66c88a7da6d205768119f51af0f684fe7be7bac8328e217a52c/regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", size = 482669 }, + { url = "https://files.pythonhosted.org/packages/34/4c/8f8e631fcdc2ff978609eaeef1d6994bf2f028b59d9ac67640ed051f1218/regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", size = 287684 }, + { url = "https://files.pythonhosted.org/packages/c5/1b/f0e4d13e6adf866ce9b069e191f303a30ab1277e037037a365c3aad5cc9c/regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", size = 284589 }, + { url = "https://files.pythonhosted.org/packages/25/4d/ab21047f446693887f25510887e6820b93f791992994f6498b0318904d4a/regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", size = 792121 }, + { url = "https://files.pythonhosted.org/packages/45/ee/c867e15cd894985cb32b731d89576c41a4642a57850c162490ea34b78c3b/regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", size = 831275 }, + { url = "https://files.pythonhosted.org/packages/b3/12/b0f480726cf1c60f6536fa5e1c95275a77624f3ac8fdccf79e6727499e28/regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", size = 818257 }, + { url = "https://files.pythonhosted.org/packages/bf/ce/0d0e61429f603bac433910d99ef1a02ce45a8967ffbe3cbee48599e62d88/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", size = 792727 }, + { url = "https://files.pythonhosted.org/packages/e4/c1/243c83c53d4a419c1556f43777ccb552bccdf79d08fda3980e4e77dd9137/regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", size = 780667 }, + { url = "https://files.pythonhosted.org/packages/c5/f4/75eb0dd4ce4b37f04928987f1d22547ddaf6c4bae697623c1b05da67a8aa/regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", size = 776963 }, + { url = "https://files.pythonhosted.org/packages/16/5d/95c568574e630e141a69ff8a254c2f188b4398e813c40d49228c9bbd9875/regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", size = 784700 }, + { url = "https://files.pythonhosted.org/packages/8e/b5/f8495c7917f15cc6fee1e7f395e324ec3e00ab3c665a7dc9d27562fd5290/regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", size = 848592 }, + { url = "https://files.pythonhosted.org/packages/1c/80/6dd7118e8cb212c3c60b191b932dc57db93fb2e36fb9e0e92f72a5909af9/regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", size = 852929 }, + { url = "https://files.pythonhosted.org/packages/11/9b/5a05d2040297d2d254baf95eeeb6df83554e5e1df03bc1a6687fc4ba1f66/regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", size = 781213 }, + { url = "https://files.pythonhosted.org/packages/26/b7/b14e2440156ab39e0177506c08c18accaf2b8932e39fb092074de733d868/regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", size = 261734 }, + { url = "https://files.pythonhosted.org/packages/80/32/763a6cc01d21fb3819227a1cc3f60fd251c13c37c27a73b8ff4315433a8e/regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", size = 274052 }, + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "responses" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/24/1d67c8974daa502e860b4a5b57ad6de0d7dbc0b1160ef7148189a24a40e1/responses-0.25.3.tar.gz", hash = "sha256:617b9247abd9ae28313d57a75880422d55ec63c29d33d629697590a034358dba", size = 77798 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/24/93293d0be0db9da1ed8dfc5e6af700fdd40e8f10a928704dd179db9f03c1/responses-0.25.3-py3-none-any.whl", hash = "sha256:521efcbc82081ab8daa588e08f7e8a64ce79b91c39f6e62199b19159bea7dbcb", size = 55238 }, +] + +[[package]] +name = "ruff" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512 }, + { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436 }, + { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936 }, + { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353 }, + { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078 }, + { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823 }, + { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855 }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580 }, + { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057 }, + { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137 }, + { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243 }, + { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187 }, + { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715 }, + { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912 }, + { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767 }, + { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534 }, + { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/3c/4f2a430c01a22abd49a583b6b944173e39e7d01b688190a5618bd59a2e22/types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", size = 18065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/01/485b3026ff90e5190b5e24f1711522e06c79f4a56c8f4b95848ac072e20f/types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747", size = 15836 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "unifi-access" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "responses" }, + { name = "ruff" }, + { name = "types-requests" }, +] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.9.2" }, + { name = "requests", specifier = ">=2.32.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.13" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "responses", specifier = ">=0.25.3" }, + { name = "ruff", specifier = ">=0.7.1" }, + { name = "types-requests", specifier = ">=2.32.0.20241016" }, +] +docs = [ + { name = "mkdocs-material", specifier = ">=9.5.42" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.26.2" }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +]