moco-py 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moco_py-1.0.0/.github/workflows/release.yml +46 -0
- moco_py-1.0.0/.gitignore +46 -0
- moco_py-1.0.0/.pre-commit-config.yaml +6 -0
- moco_py-1.0.0/CHANGELOG.md +7 -0
- moco_py-1.0.0/PKG-INFO +30 -0
- moco_py-1.0.0/README.md +17 -0
- moco_py-1.0.0/pyproject.toml +72 -0
- moco_py-1.0.0/src/moco_py/__init__.py +36 -0
- moco_py-1.0.0/src/moco_py/_constants.py +9 -0
- moco_py-1.0.0/src/moco_py/_pagination.py +160 -0
- moco_py-1.0.0/src/moco_py/_resource.py +157 -0
- moco_py-1.0.0/src/moco_py/_response.py +27 -0
- moco_py-1.0.0/src/moco_py/_transport.py +401 -0
- moco_py-1.0.0/src/moco_py/client.py +707 -0
- moco_py-1.0.0/src/moco_py/exceptions.py +125 -0
- moco_py-1.0.0/src/moco_py/py.typed +0 -0
- moco_py-1.0.0/src/moco_py/resources/__init__.py +0 -0
- moco_py-1.0.0/src/moco_py/resources/account_custom_properties.py +224 -0
- moco_py-1.0.0/src/moco_py/resources/account_web_hooks.py +107 -0
- moco_py-1.0.0/src/moco_py/resources/activities.py +349 -0
- moco_py-1.0.0/src/moco_py/resources/comments.py +185 -0
- moco_py-1.0.0/src/moco_py/resources/companies.py +329 -0
- moco_py-1.0.0/src/moco_py/resources/contacts.py +307 -0
- moco_py-1.0.0/src/moco_py/resources/deal_categories.py +104 -0
- moco_py-1.0.0/src/moco_py/resources/deals.py +273 -0
- moco_py-1.0.0/src/moco_py/resources/employments.py +158 -0
- moco_py-1.0.0/src/moco_py/resources/holidays.py +158 -0
- moco_py-1.0.0/src/moco_py/resources/invoice_bookkeeping_exports.py +84 -0
- moco_py-1.0.0/src/moco_py/resources/invoice_payments.py +198 -0
- moco_py-1.0.0/src/moco_py/resources/invoice_reminders.py +180 -0
- moco_py-1.0.0/src/moco_py/resources/invoices.py +498 -0
- moco_py-1.0.0/src/moco_py/resources/offers.py +383 -0
- moco_py-1.0.0/src/moco_py/resources/planning_entries.py +239 -0
- moco_py-1.0.0/src/moco_py/resources/presences.py +202 -0
- moco_py-1.0.0/src/moco_py/resources/profile.py +23 -0
- moco_py-1.0.0/src/moco_py/resources/project_contracts.py +158 -0
- moco_py-1.0.0/src/moco_py/resources/project_expenses.py +448 -0
- moco_py-1.0.0/src/moco_py/resources/project_groups.py +34 -0
- moco_py-1.0.0/src/moco_py/resources/project_payment_schedules.py +248 -0
- moco_py-1.0.0/src/moco_py/resources/project_recurring_expenses.py +294 -0
- moco_py-1.0.0/src/moco_py/resources/project_tasks.py +176 -0
- moco_py-1.0.0/src/moco_py/resources/projects.py +616 -0
- moco_py-1.0.0/src/moco_py/resources/purchase_categories.py +36 -0
- moco_py-1.0.0/src/moco_py/resources/purchase_drafts.py +40 -0
- moco_py-1.0.0/src/moco_py/resources/purchase_payments.py +178 -0
- moco_py-1.0.0/src/moco_py/resources/purchases.py +435 -0
- moco_py-1.0.0/src/moco_py/resources/receipts.py +218 -0
- moco_py-1.0.0/src/moco_py/resources/reports.py +199 -0
- moco_py-1.0.0/src/moco_py/resources/schedules.py +207 -0
- moco_py-1.0.0/src/moco_py/resources/tags.py +110 -0
- moco_py-1.0.0/src/moco_py/resources/units.py +64 -0
- moco_py-1.0.0/src/moco_py/resources/user_roles.py +23 -0
- moco_py-1.0.0/src/moco_py/resources/users.py +286 -0
- moco_py-1.0.0/src/moco_py/resources/vat_codes.py +62 -0
- moco_py-1.0.0/src/moco_py/resources/work_time_adjustments.py +174 -0
- moco_py-1.0.0/src/moco_py/types/__init__.py +0 -0
- moco_py-1.0.0/src/moco_py/types/_embedded.py +44 -0
- moco_py-1.0.0/src/moco_py/types/_enums.py +330 -0
- moco_py-1.0.0/src/moco_py/types/account_custom_properties.py +26 -0
- moco_py-1.0.0/src/moco_py/types/account_web_hooks.py +22 -0
- moco_py-1.0.0/src/moco_py/types/activities.py +64 -0
- moco_py-1.0.0/src/moco_py/types/comments.py +23 -0
- moco_py-1.0.0/src/moco_py/types/companies.py +83 -0
- moco_py-1.0.0/src/moco_py/types/contacts.py +41 -0
- moco_py-1.0.0/src/moco_py/types/deal_categories.py +17 -0
- moco_py-1.0.0/src/moco_py/types/deals.py +55 -0
- moco_py-1.0.0/src/moco_py/types/employments.py +31 -0
- moco_py-1.0.0/src/moco_py/types/holidays.py +23 -0
- moco_py-1.0.0/src/moco_py/types/invoice_bookkeeping_exports.py +25 -0
- moco_py-1.0.0/src/moco_py/types/invoice_payments.py +23 -0
- moco_py-1.0.0/src/moco_py/types/invoice_reminders.py +25 -0
- moco_py-1.0.0/src/moco_py/types/invoices.py +174 -0
- moco_py-1.0.0/src/moco_py/types/offers.py +115 -0
- moco_py-1.0.0/src/moco_py/types/planning_entries.py +60 -0
- moco_py-1.0.0/src/moco_py/types/presences.py +24 -0
- moco_py-1.0.0/src/moco_py/types/profile.py +30 -0
- moco_py-1.0.0/src/moco_py/types/project_contracts.py +22 -0
- moco_py-1.0.0/src/moco_py/types/project_expenses.py +84 -0
- moco_py-1.0.0/src/moco_py/types/project_groups.py +50 -0
- moco_py-1.0.0/src/moco_py/types/project_payment_schedules.py +30 -0
- moco_py-1.0.0/src/moco_py/types/project_recurring_expenses.py +44 -0
- moco_py-1.0.0/src/moco_py/types/project_tasks.py +24 -0
- moco_py-1.0.0/src/moco_py/types/projects.py +158 -0
- moco_py-1.0.0/src/moco_py/types/purchase_categories.py +18 -0
- moco_py-1.0.0/src/moco_py/types/purchase_drafts.py +28 -0
- moco_py-1.0.0/src/moco_py/types/purchase_payments.py +26 -0
- moco_py-1.0.0/src/moco_py/types/purchases.py +139 -0
- moco_py-1.0.0/src/moco_py/types/receipts.py +72 -0
- moco_py-1.0.0/src/moco_py/types/reports.py +112 -0
- moco_py-1.0.0/src/moco_py/types/schedules.py +43 -0
- moco_py-1.0.0/src/moco_py/types/tags.py +18 -0
- moco_py-1.0.0/src/moco_py/types/units.py +25 -0
- moco_py-1.0.0/src/moco_py/types/user_roles.py +35 -0
- moco_py-1.0.0/src/moco_py/types/users.py +75 -0
- moco_py-1.0.0/src/moco_py/types/vat_codes.py +31 -0
- moco_py-1.0.0/src/moco_py/types/work_time_adjustments.py +22 -0
- moco_py-1.0.0/tests/__init__.py +0 -0
- moco_py-1.0.0/tests/integration/__init__.py +0 -0
- moco_py-1.0.0/tests/integration/conftest.py +59 -0
- moco_py-1.0.0/tests/integration/test_activities.py +57 -0
- moco_py-1.0.0/tests/integration/test_comments.py +30 -0
- moco_py-1.0.0/tests/integration/test_companies.py +54 -0
- moco_py-1.0.0/tests/integration/test_contacts.py +39 -0
- moco_py-1.0.0/tests/integration/test_deals.py +50 -0
- moco_py-1.0.0/tests/integration/test_projects.py +64 -0
- moco_py-1.0.0/tests/integration/test_tags.py +34 -0
- moco_py-1.0.0/tests/integration/test_users.py +26 -0
- moco_py-1.0.0/tests/test_account_custom_properties.py +130 -0
- moco_py-1.0.0/tests/test_account_web_hooks.py +131 -0
- moco_py-1.0.0/tests/test_activities.py +203 -0
- moco_py-1.0.0/tests/test_client.py +76 -0
- moco_py-1.0.0/tests/test_comments.py +142 -0
- moco_py-1.0.0/tests/test_companies.py +181 -0
- moco_py-1.0.0/tests/test_contacts.py +140 -0
- moco_py-1.0.0/tests/test_deal_categories.py +97 -0
- moco_py-1.0.0/tests/test_deals.py +146 -0
- moco_py-1.0.0/tests/test_employments.py +116 -0
- moco_py-1.0.0/tests/test_exceptions.py +107 -0
- moco_py-1.0.0/tests/test_holidays.py +108 -0
- moco_py-1.0.0/tests/test_invoice_bookkeeping_exports.py +88 -0
- moco_py-1.0.0/tests/test_invoice_payments.py +141 -0
- moco_py-1.0.0/tests/test_invoice_reminders.py +115 -0
- moco_py-1.0.0/tests/test_invoices.py +277 -0
- moco_py-1.0.0/tests/test_offers.py +164 -0
- moco_py-1.0.0/tests/test_pagination.py +144 -0
- moco_py-1.0.0/tests/test_planning_entries.py +162 -0
- moco_py-1.0.0/tests/test_presences.py +117 -0
- moco_py-1.0.0/tests/test_profile.py +51 -0
- moco_py-1.0.0/tests/test_project_contracts.py +103 -0
- moco_py-1.0.0/tests/test_project_expenses.py +185 -0
- moco_py-1.0.0/tests/test_project_groups.py +83 -0
- moco_py-1.0.0/tests/test_project_payment_schedules.py +117 -0
- moco_py-1.0.0/tests/test_project_recurring_expenses.py +165 -0
- moco_py-1.0.0/tests/test_project_tasks.py +118 -0
- moco_py-1.0.0/tests/test_projects.py +303 -0
- moco_py-1.0.0/tests/test_purchase_categories.py +67 -0
- moco_py-1.0.0/tests/test_purchase_drafts.py +79 -0
- moco_py-1.0.0/tests/test_purchase_payments.py +108 -0
- moco_py-1.0.0/tests/test_purchases.py +201 -0
- moco_py-1.0.0/tests/test_receipts.py +153 -0
- moco_py-1.0.0/tests/test_reports.py +194 -0
- moco_py-1.0.0/tests/test_resource.py +89 -0
- moco_py-1.0.0/tests/test_response.py +46 -0
- moco_py-1.0.0/tests/test_schedules.py +136 -0
- moco_py-1.0.0/tests/test_tags.py +114 -0
- moco_py-1.0.0/tests/test_transport.py +202 -0
- moco_py-1.0.0/tests/test_units.py +108 -0
- moco_py-1.0.0/tests/test_user_roles.py +62 -0
- moco_py-1.0.0/tests/test_users.py +157 -0
- moco_py-1.0.0/tests/test_vat_codes.py +108 -0
- moco_py-1.0.0/tests/test_work_time_adjustments.py +119 -0
- moco_py-1.0.0/uv.lock +1329 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
if: "!startsWith(github.event.head_commit.message, 'chore(release):')"
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
21
|
+
|
|
22
|
+
- name: Install uv
|
|
23
|
+
uses: astral-sh/setup-uv@v6
|
|
24
|
+
|
|
25
|
+
- name: Set up Python
|
|
26
|
+
uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: uv sync
|
|
32
|
+
|
|
33
|
+
- name: Run semantic-release
|
|
34
|
+
env:
|
|
35
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
36
|
+
run: |
|
|
37
|
+
git config user.name "github-actions[bot]"
|
|
38
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
39
|
+
uv run semantic-release version
|
|
40
|
+
uv run semantic-release publish
|
|
41
|
+
|
|
42
|
+
- name: Publish to PyPI
|
|
43
|
+
if: hashFiles('dist/*') != ''
|
|
44
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
45
|
+
with:
|
|
46
|
+
packages-dir: dist/
|
moco_py-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.venv/
|
|
9
|
+
|
|
10
|
+
# Testing
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
|
|
15
|
+
# Type checking / Linting
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
*~
|
|
25
|
+
|
|
26
|
+
# AI Code Assistants
|
|
27
|
+
.claude/
|
|
28
|
+
.cursor/
|
|
29
|
+
.aider*
|
|
30
|
+
.codeium/
|
|
31
|
+
.continue/
|
|
32
|
+
.copilot/
|
|
33
|
+
.tabby/
|
|
34
|
+
CLAUDE.md
|
|
35
|
+
AGENTS.md
|
|
36
|
+
|
|
37
|
+
# OS
|
|
38
|
+
.DS_Store
|
|
39
|
+
Thumbs.db
|
|
40
|
+
|
|
41
|
+
# Environment
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
|
|
45
|
+
# official docs for reference
|
|
46
|
+
mocoapp-api-docs-master/
|
moco_py-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moco-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python client library for the MOCO ERP API
|
|
5
|
+
Project-URL: Homepage, https://github.com/bandbyte/moco-py
|
|
6
|
+
Project-URL: Documentation, https://github.com/bandbyte/moco-py
|
|
7
|
+
Project-URL: Repository, https://github.com/bandbyte/moco-py
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# moco-py
|
|
15
|
+
|
|
16
|
+
Python client library for the [MOCO ERP API](https://www.mocoapp.com).
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install moco-py
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from moco_py import Moco
|
|
28
|
+
|
|
29
|
+
client = Moco(domain="yourcompany", api_key="your-api-key")
|
|
30
|
+
```
|
moco_py-1.0.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# moco-py
|
|
2
|
+
|
|
3
|
+
Python client library for the [MOCO ERP API](https://www.mocoapp.com).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install moco-py
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from moco_py import Moco
|
|
15
|
+
|
|
16
|
+
client = Moco(domain="yourcompany", api_key="your-api-key")
|
|
17
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "moco-py"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Python client library for the MOCO ERP API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"pydantic>=2.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/bandbyte/moco-py"
|
|
15
|
+
Documentation = "https://github.com/bandbyte/moco-py"
|
|
16
|
+
Repository = "https://github.com/bandbyte/moco-py"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/moco_py"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
markers = [
|
|
28
|
+
"integration: tests that hit a live MOCO API (require MOCO_API_KEY and MOCO_DOMAIN env vars)",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
src = ["src"]
|
|
33
|
+
target-version = "py310"
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "UP"]
|
|
37
|
+
|
|
38
|
+
[tool.mypy]
|
|
39
|
+
python_version = "3.10"
|
|
40
|
+
strict = true
|
|
41
|
+
packages = ["moco_py"]
|
|
42
|
+
mypy_path = "src"
|
|
43
|
+
|
|
44
|
+
[tool.commitizen]
|
|
45
|
+
name = "cz_conventional_commits"
|
|
46
|
+
version_provider = "pep621"
|
|
47
|
+
tag_format = "v$version"
|
|
48
|
+
update_changelog_on_bump = true
|
|
49
|
+
|
|
50
|
+
[tool.semantic_release]
|
|
51
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
52
|
+
version_variables = ["src/moco_py/__init__.py:__version__"]
|
|
53
|
+
tag_format = "v{version}"
|
|
54
|
+
build_command = "uv build"
|
|
55
|
+
|
|
56
|
+
[tool.semantic_release.changelog.default_templates]
|
|
57
|
+
changelog_file = "CHANGELOG.md"
|
|
58
|
+
|
|
59
|
+
[tool.semantic_release.branches.main]
|
|
60
|
+
match = "main"
|
|
61
|
+
|
|
62
|
+
[dependency-groups]
|
|
63
|
+
dev = [
|
|
64
|
+
"commitizen>=4.13.9",
|
|
65
|
+
"mypy>=1.0",
|
|
66
|
+
"pre-commit>=4.5.1",
|
|
67
|
+
"pytest>=8.0",
|
|
68
|
+
"pytest-asyncio>=0.24",
|
|
69
|
+
"python-semantic-release>=10.5.3",
|
|
70
|
+
"respx>=0.22",
|
|
71
|
+
"ruff>=0.8",
|
|
72
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""MOCO ERP API client library."""
|
|
2
|
+
|
|
3
|
+
from ._pagination import AsyncPage, SyncPage
|
|
4
|
+
from ._response import MocoResponse
|
|
5
|
+
from .client import AsyncMoco, Moco
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
MocoError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PermissionError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "1.0.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncMoco",
|
|
22
|
+
"AsyncPage",
|
|
23
|
+
"Moco",
|
|
24
|
+
"MocoResponse",
|
|
25
|
+
"SyncPage",
|
|
26
|
+
"__version__",
|
|
27
|
+
"APIConnectionError",
|
|
28
|
+
"APITimeoutError",
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"MocoError",
|
|
31
|
+
"NotFoundError",
|
|
32
|
+
"PermissionError",
|
|
33
|
+
"RateLimitError",
|
|
34
|
+
"ServerError",
|
|
35
|
+
"ValidationError",
|
|
36
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Internal constants for the MOCO API client."""
|
|
2
|
+
|
|
3
|
+
BASE_URL_TEMPLATE = "https://{domain}.mocoapp.com/api/v1"
|
|
4
|
+
ENV_API_KEY = "MOCO_API_KEY"
|
|
5
|
+
ENV_DOMAIN = "MOCO_DOMAIN"
|
|
6
|
+
DEFAULT_TIMEOUT = 30.0
|
|
7
|
+
DEFAULT_MAX_RETRIES = 2
|
|
8
|
+
DEFAULT_PER_PAGE = 100
|
|
9
|
+
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Header-based pagination for the MOCO API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import TypeAdapter
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
_LINK_NEXT_RE = re.compile(r'<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="next"')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_link_next(link: str | None) -> int | None:
|
|
22
|
+
"""Extract the next page number from a Link header with rel="next"."""
|
|
23
|
+
if link is None:
|
|
24
|
+
return None
|
|
25
|
+
match = _LINK_NEXT_RE.search(link)
|
|
26
|
+
if match is None:
|
|
27
|
+
return None
|
|
28
|
+
return int(match.group(1))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PageInfo:
|
|
33
|
+
"""Metadata from pagination response headers."""
|
|
34
|
+
|
|
35
|
+
page: int
|
|
36
|
+
per_page: int
|
|
37
|
+
total: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SyncPage(Generic[T]):
|
|
41
|
+
"""A single page of results from a paginated MOCO API endpoint."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
items: list[T],
|
|
47
|
+
http_response: httpx.Response,
|
|
48
|
+
transport: SyncTransport,
|
|
49
|
+
path: str,
|
|
50
|
+
params: dict[str, object],
|
|
51
|
+
cast_to: type[T],
|
|
52
|
+
) -> None:
|
|
53
|
+
self.items = items
|
|
54
|
+
self.http_response = http_response
|
|
55
|
+
self._transport = transport
|
|
56
|
+
self._path = path
|
|
57
|
+
self._params = params
|
|
58
|
+
self._cast_to = cast_to
|
|
59
|
+
|
|
60
|
+
headers = http_response.headers
|
|
61
|
+
self.page_info = PageInfo(
|
|
62
|
+
page=int(headers.get("X-Page", "1")),
|
|
63
|
+
per_page=int(headers.get("X-Per-Page", "100")),
|
|
64
|
+
total=int(headers.get("X-Total", str(len(items)))),
|
|
65
|
+
)
|
|
66
|
+
self._next_page = _parse_link_next(headers.get("Link"))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_next(self) -> bool:
|
|
70
|
+
return self._next_page is not None
|
|
71
|
+
|
|
72
|
+
def next_page(self) -> SyncPage[T]:
|
|
73
|
+
"""Fetch the next page of results."""
|
|
74
|
+
if self._next_page is None:
|
|
75
|
+
raise StopIteration("No more pages")
|
|
76
|
+
params = {**self._params, "page": self._next_page}
|
|
77
|
+
response = self._transport.request_raw("GET", self._path, params=params)
|
|
78
|
+
adapter = TypeAdapter(list[self._cast_to]) # type: ignore[name-defined]
|
|
79
|
+
items = adapter.validate_json(response.content)
|
|
80
|
+
return SyncPage(
|
|
81
|
+
items=items,
|
|
82
|
+
http_response=response,
|
|
83
|
+
transport=self._transport,
|
|
84
|
+
path=self._path,
|
|
85
|
+
params=self._params,
|
|
86
|
+
cast_to=self._cast_to,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def __iter__(self) -> Iterator[T]:
|
|
90
|
+
yield from self.items
|
|
91
|
+
|
|
92
|
+
def auto_paging_iter(self) -> Iterator[T]:
|
|
93
|
+
"""Iterate over all items across all pages."""
|
|
94
|
+
page: SyncPage[T] = self
|
|
95
|
+
while True:
|
|
96
|
+
yield from page.items
|
|
97
|
+
if not page.has_next:
|
|
98
|
+
break
|
|
99
|
+
page = page.next_page()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AsyncPage(Generic[T]):
|
|
103
|
+
"""A single page of results from a paginated MOCO API endpoint (async)."""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
items: list[T],
|
|
109
|
+
http_response: httpx.Response,
|
|
110
|
+
transport: AsyncTransport,
|
|
111
|
+
path: str,
|
|
112
|
+
params: dict[str, object],
|
|
113
|
+
cast_to: type[T],
|
|
114
|
+
) -> None:
|
|
115
|
+
self.items = items
|
|
116
|
+
self.http_response = http_response
|
|
117
|
+
self._transport = transport
|
|
118
|
+
self._path = path
|
|
119
|
+
self._params = params
|
|
120
|
+
self._cast_to = cast_to
|
|
121
|
+
|
|
122
|
+
headers = http_response.headers
|
|
123
|
+
self.page_info = PageInfo(
|
|
124
|
+
page=int(headers.get("X-Page", "1")),
|
|
125
|
+
per_page=int(headers.get("X-Per-Page", "100")),
|
|
126
|
+
total=int(headers.get("X-Total", str(len(items)))),
|
|
127
|
+
)
|
|
128
|
+
self._next_page = _parse_link_next(headers.get("Link"))
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def has_next(self) -> bool:
|
|
132
|
+
return self._next_page is not None
|
|
133
|
+
|
|
134
|
+
async def next_page(self) -> AsyncPage[T]:
|
|
135
|
+
if self._next_page is None:
|
|
136
|
+
raise StopAsyncIteration("No more pages")
|
|
137
|
+
params = {**self._params, "page": self._next_page}
|
|
138
|
+
response = await self._transport.request_raw("GET", self._path, params=params)
|
|
139
|
+
adapter = TypeAdapter(list[self._cast_to]) # type: ignore[name-defined]
|
|
140
|
+
items = adapter.validate_json(response.content)
|
|
141
|
+
return AsyncPage(
|
|
142
|
+
items=items,
|
|
143
|
+
http_response=response,
|
|
144
|
+
transport=self._transport,
|
|
145
|
+
path=self._path,
|
|
146
|
+
params=self._params,
|
|
147
|
+
cast_to=self._cast_to,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __iter__(self) -> Iterator[T]:
|
|
151
|
+
yield from self.items
|
|
152
|
+
|
|
153
|
+
async def auto_paging_iter(self) -> AsyncIterator[T]:
|
|
154
|
+
page: AsyncPage[T] = self
|
|
155
|
+
while True:
|
|
156
|
+
for item in page.items:
|
|
157
|
+
yield item
|
|
158
|
+
if not page.has_next:
|
|
159
|
+
break
|
|
160
|
+
page = await page.next_page()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Base resource classes for sync and async API access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import TypeAdapter
|
|
8
|
+
|
|
9
|
+
from ._pagination import AsyncPage, SyncPage
|
|
10
|
+
from ._response import MocoResponse
|
|
11
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncResource:
|
|
17
|
+
"""Base class for synchronous API resource namespaces."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
20
|
+
self._transport = transport
|
|
21
|
+
|
|
22
|
+
def _get(
|
|
23
|
+
self,
|
|
24
|
+
path: str,
|
|
25
|
+
*,
|
|
26
|
+
params: dict[str, Any] | None = None,
|
|
27
|
+
cast_to: type[T],
|
|
28
|
+
) -> MocoResponse[T]:
|
|
29
|
+
return self._transport.request("GET", path, params=params, cast_to=cast_to)
|
|
30
|
+
|
|
31
|
+
def _get_list(
|
|
32
|
+
self,
|
|
33
|
+
path: str,
|
|
34
|
+
*,
|
|
35
|
+
params: dict[str, Any] | None = None,
|
|
36
|
+
cast_to: type[T],
|
|
37
|
+
) -> SyncPage[T]:
|
|
38
|
+
response = self._transport.request_raw("GET", path, params=params)
|
|
39
|
+
adapter = TypeAdapter(list[cast_to]) # type: ignore[valid-type]
|
|
40
|
+
items = adapter.validate_json(response.content)
|
|
41
|
+
return SyncPage(
|
|
42
|
+
items=items,
|
|
43
|
+
http_response=response,
|
|
44
|
+
transport=self._transport,
|
|
45
|
+
path=path,
|
|
46
|
+
params=params or {},
|
|
47
|
+
cast_to=cast_to,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _post(
|
|
51
|
+
self,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
json_data: Any = None,
|
|
55
|
+
cast_to: type[T],
|
|
56
|
+
) -> MocoResponse[T]:
|
|
57
|
+
return self._transport.request(
|
|
58
|
+
"POST", path, json_data=json_data, cast_to=cast_to
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _put(
|
|
62
|
+
self,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
json_data: Any = None,
|
|
66
|
+
cast_to: type[T],
|
|
67
|
+
) -> MocoResponse[T]:
|
|
68
|
+
return self._transport.request(
|
|
69
|
+
"PUT", path, json_data=json_data, cast_to=cast_to
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _patch(
|
|
73
|
+
self,
|
|
74
|
+
path: str,
|
|
75
|
+
*,
|
|
76
|
+
json_data: Any = None,
|
|
77
|
+
cast_to: type[T],
|
|
78
|
+
) -> MocoResponse[T]:
|
|
79
|
+
return self._transport.request(
|
|
80
|
+
"PATCH", path, json_data=json_data, cast_to=cast_to
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _delete(self, path: str) -> MocoResponse[None]:
|
|
84
|
+
return self._transport.request("DELETE", path, cast_to=None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AsyncResource:
|
|
88
|
+
"""Base class for asynchronous API resource namespaces."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
91
|
+
self._transport = transport
|
|
92
|
+
|
|
93
|
+
async def _get(
|
|
94
|
+
self,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
params: dict[str, Any] | None = None,
|
|
98
|
+
cast_to: type[T],
|
|
99
|
+
) -> MocoResponse[Any]:
|
|
100
|
+
return await self._transport.request(
|
|
101
|
+
"GET", path, params=params, cast_to=cast_to
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def _get_list(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
*,
|
|
108
|
+
params: dict[str, Any] | None = None,
|
|
109
|
+
cast_to: type[T],
|
|
110
|
+
) -> AsyncPage[T]:
|
|
111
|
+
response = await self._transport.request_raw("GET", path, params=params)
|
|
112
|
+
adapter = TypeAdapter(list[cast_to]) # type: ignore[valid-type]
|
|
113
|
+
items = adapter.validate_json(response.content)
|
|
114
|
+
return AsyncPage(
|
|
115
|
+
items=items,
|
|
116
|
+
http_response=response,
|
|
117
|
+
transport=self._transport,
|
|
118
|
+
path=path,
|
|
119
|
+
params=params or {},
|
|
120
|
+
cast_to=cast_to,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def _post(
|
|
124
|
+
self,
|
|
125
|
+
path: str,
|
|
126
|
+
*,
|
|
127
|
+
json_data: Any = None,
|
|
128
|
+
cast_to: type[T],
|
|
129
|
+
) -> MocoResponse[Any]:
|
|
130
|
+
return await self._transport.request(
|
|
131
|
+
"POST", path, json_data=json_data, cast_to=cast_to
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def _put(
|
|
135
|
+
self,
|
|
136
|
+
path: str,
|
|
137
|
+
*,
|
|
138
|
+
json_data: Any = None,
|
|
139
|
+
cast_to: type[T],
|
|
140
|
+
) -> MocoResponse[Any]:
|
|
141
|
+
return await self._transport.request(
|
|
142
|
+
"PUT", path, json_data=json_data, cast_to=cast_to
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def _patch(
|
|
146
|
+
self,
|
|
147
|
+
path: str,
|
|
148
|
+
*,
|
|
149
|
+
json_data: Any = None,
|
|
150
|
+
cast_to: type[T],
|
|
151
|
+
) -> MocoResponse[Any]:
|
|
152
|
+
return await self._transport.request(
|
|
153
|
+
"PATCH", path, json_data=json_data, cast_to=cast_to
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def _delete(self, path: str) -> MocoResponse[Any]:
|
|
157
|
+
return await self._transport.request("DELETE", path, cast_to=None)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Response wrapper providing typed access alongside raw httpx.Response."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MocoResponse(Generic[T]):
|
|
13
|
+
"""Wraps a parsed Pydantic model with access to the raw HTTP response."""
|
|
14
|
+
|
|
15
|
+
__slots__ = ("parsed", "http_response")
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, parsed: T, http_response: httpx.Response) -> None:
|
|
18
|
+
self.parsed = parsed
|
|
19
|
+
self.http_response = http_response
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def status_code(self) -> int:
|
|
23
|
+
return self.http_response.status_code
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def headers(self) -> httpx.Headers:
|
|
27
|
+
return self.http_response.headers
|