moneybird-cli 0.2.0__tar.gz → 0.2.2__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.
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/.github/workflows/release.yml +5 -2
- moneybird_cli-0.2.2/.github/workflows/tests.yml +32 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/.gitignore +1 -0
- {moneybird_cli-0.2.0/src/moneybird_cli.egg-info → moneybird_cli-0.2.2}/PKG-INFO +36 -4
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/README.md +7 -1
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/getting-started.md +15 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/pyproject.toml +7 -2
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/client.py +41 -8
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/login.py +2 -2
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/config.py +10 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/main.py +18 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2/src/moneybird_cli.egg-info}/PKG-INFO +36 -4
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/SOURCES.txt +2 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/requires.txt +3 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/scm_file_list.json +3 -1
- moneybird_cli-0.2.2/src/moneybird_cli.egg-info/scm_version.json +8 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_client.py +71 -0
- moneybird_cli-0.2.2/tox.ini +23 -0
- moneybird_cli-0.2.0/src/moneybird_cli.egg-info/scm_version.json +0 -8
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/LICENSE +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/contacts.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/documents.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/financial.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/invoicing.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/products-and-subscriptions.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/reports.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/settings.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/time-tracking.md +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/setup.cfg +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/__init__.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/__init__.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/_common.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/administration.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/assets.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/contacts.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/documents.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/estimates.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/external_sales_invoices.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_accounts.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_mutations.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_statements.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/ledger_accounts.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/payments.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/products.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/projects.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/purchase_transactions.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/recurring_sales_invoices.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/reports.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/sales_invoices.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/subscriptions.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/task_lists.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/tax_rates.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/time_entries.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/webhooks.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/output.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/dependency_links.txt +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/entry_points.txt +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/top_level.txt +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/__init__.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/conftest.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/__init__.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_contacts.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_financial_mutations.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_login.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_sales_invoices.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_config.py +0 -0
- {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_output.py +0 -0
|
@@ -5,7 +5,11 @@ on:
|
|
|
5
5
|
tags: ["v*"]
|
|
6
6
|
|
|
7
7
|
jobs:
|
|
8
|
+
test:
|
|
9
|
+
uses: ./.github/workflows/tests.yml
|
|
10
|
+
|
|
8
11
|
build:
|
|
12
|
+
needs: test
|
|
9
13
|
runs-on: ubuntu-latest
|
|
10
14
|
steps:
|
|
11
15
|
- uses: actions/checkout@v4
|
|
@@ -14,8 +18,7 @@ jobs:
|
|
|
14
18
|
- uses: actions/setup-python@v5
|
|
15
19
|
with:
|
|
16
20
|
python-version: "3.13"
|
|
17
|
-
- run: pip install
|
|
18
|
-
- run: pytest -q
|
|
21
|
+
- run: pip install build
|
|
19
22
|
- run: python -m build
|
|
20
23
|
- uses: actions/upload-artifact@v4
|
|
21
24
|
with:
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["**"]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
tox:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
16
|
+
dependencies: [min, latest]
|
|
17
|
+
include:
|
|
18
|
+
- python: "3.8"
|
|
19
|
+
dependencies: min-devmin
|
|
20
|
+
- python: "3.8"
|
|
21
|
+
dependencies: build
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
- uses: astral-sh/setup-uv@v6
|
|
27
|
+
- run: uv python install "${{ matrix.python }}"
|
|
28
|
+
- name: Run tox
|
|
29
|
+
env:
|
|
30
|
+
PYTHON_VERSION: ${{ matrix.python }}
|
|
31
|
+
DEPENDENCIES: ${{ matrix.dependencies }}
|
|
32
|
+
run: uvx --from tox --with tox-uv tox -e "py${PYTHON_VERSION//./}-${DEPENDENCIES}"
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moneybird-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Command-line interface for the Moneybird API
|
|
5
5
|
Author-email: Koert van der Veer <koert@ondergetekende.nl>
|
|
6
|
-
License
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Koert van der Veer
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
7
28
|
Project-URL: Homepage, https://github.com/ondergetekende/moneybird-cli
|
|
8
29
|
Project-URL: Repository, https://github.com/ondergetekende/moneybird-cli
|
|
9
30
|
Project-URL: Issues, https://github.com/ondergetekende/moneybird-cli/issues
|
|
@@ -13,16 +34,21 @@ Classifier: Environment :: Console
|
|
|
13
34
|
Classifier: Intended Audience :: End Users/Desktop
|
|
14
35
|
Classifier: Intended Audience :: System Administrators
|
|
15
36
|
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
40
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
41
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
42
|
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
44
|
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
|
-
Requires-Python: >=3.
|
|
45
|
+
Requires-Python: >=3.8
|
|
21
46
|
Description-Content-Type: text/markdown
|
|
22
47
|
License-File: LICENSE
|
|
23
48
|
Requires-Dist: click>=8.1
|
|
24
49
|
Requires-Dist: requests>=2.31
|
|
25
50
|
Requires-Dist: tabulate>=0.9
|
|
51
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
26
52
|
Provides-Extra: dev
|
|
27
53
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
54
|
Requires-Dist: responses>=0.23; extra == "dev"
|
|
@@ -61,11 +87,17 @@ See [Getting Started](docs/getting-started.md) for full setup instructions, outp
|
|
|
61
87
|
|
|
62
88
|
## Tech stack
|
|
63
89
|
|
|
64
|
-
- [Python](https://www.python.org/) >= 3.
|
|
90
|
+
- [Python](https://www.python.org/) >= 3.8
|
|
65
91
|
- [Click](https://click.palletsprojects.com/) for the CLI framework
|
|
66
92
|
- [Requests](https://requests.readthedocs.io/) for HTTP
|
|
67
93
|
- [Tabulate](https://pypi.org/project/tabulate/) for table formatting
|
|
68
94
|
|
|
95
|
+
## Testing
|
|
96
|
+
|
|
97
|
+
Install tox with `python -m pip install 'tox>=4'`, then run `tox` with Python 3.8 through 3.14 available. The matrix tests minimum and current runtime dependencies on each Python version, plus minimum development dependencies and package building on Python 3.8. Run an individual environment with `tox -e py38-min`.
|
|
98
|
+
|
|
99
|
+
Keep the minimum dependency pins in `tox.ini` in sync with `pyproject.toml` when changing dependency requirements.
|
|
100
|
+
|
|
69
101
|
## Releasing
|
|
70
102
|
|
|
71
103
|
Versions come from git tags (via `setuptools-scm`). Pushing a tag like `v1.2.3` runs the tests, builds, and publishes to PyPI:
|
|
@@ -31,11 +31,17 @@ See [Getting Started](docs/getting-started.md) for full setup instructions, outp
|
|
|
31
31
|
|
|
32
32
|
## Tech stack
|
|
33
33
|
|
|
34
|
-
- [Python](https://www.python.org/) >= 3.
|
|
34
|
+
- [Python](https://www.python.org/) >= 3.8
|
|
35
35
|
- [Click](https://click.palletsprojects.com/) for the CLI framework
|
|
36
36
|
- [Requests](https://requests.readthedocs.io/) for HTTP
|
|
37
37
|
- [Tabulate](https://pypi.org/project/tabulate/) for table formatting
|
|
38
38
|
|
|
39
|
+
## Testing
|
|
40
|
+
|
|
41
|
+
Install tox with `python -m pip install 'tox>=4'`, then run `tox` with Python 3.8 through 3.14 available. The matrix tests minimum and current runtime dependencies on each Python version, plus minimum development dependencies and package building on Python 3.8. Run an individual environment with `tox -e py38-min`.
|
|
42
|
+
|
|
43
|
+
Keep the minimum dependency pins in `tox.ini` in sync with `pyproject.toml` when changing dependency requirements.
|
|
44
|
+
|
|
39
45
|
## Releasing
|
|
40
46
|
|
|
41
47
|
Versions come from git tags (via `setuptools-scm`). Pushing a tag like `v1.2.3` runs the tests, builds, and publishes to PyPI:
|
|
@@ -14,6 +14,21 @@ pip install moneybird-cli
|
|
|
14
14
|
|
|
15
15
|
This installs a `moneybird` command in your PATH.
|
|
16
16
|
|
|
17
|
+
### Shell completion
|
|
18
|
+
|
|
19
|
+
Generate a completion script for your shell and install it:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# bash
|
|
23
|
+
moneybird completion bash > ~/.local/share/bash-completion/completions/moneybird
|
|
24
|
+
# zsh (pick a directory in your $fpath)
|
|
25
|
+
moneybird completion zsh > "${fpath[1]}/_moneybird"
|
|
26
|
+
# fish
|
|
27
|
+
moneybird completion fish > ~/.config/fish/completions/moneybird.fish
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Restart your shell afterwards. Administration names from your config are completed too.
|
|
31
|
+
|
|
17
32
|
## Authentication
|
|
18
33
|
|
|
19
34
|
You need a personal API token from Moneybird:
|
|
@@ -7,8 +7,8 @@ name = "moneybird-cli"
|
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
description = "Command-line interface for the Moneybird API"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
11
|
-
license = "
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
12
|
keywords = ["moneybird", "accounting", "cli", "invoicing", "bookkeeping"]
|
|
13
13
|
authors = [
|
|
14
14
|
{ name = "Koert van der Veer", email = "koert@ondergetekende.nl" },
|
|
@@ -19,15 +19,20 @@ classifiers = [
|
|
|
19
19
|
"Intended Audience :: End Users/Desktop",
|
|
20
20
|
"Intended Audience :: System Administrators",
|
|
21
21
|
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
22
25
|
"Programming Language :: Python :: 3.11",
|
|
23
26
|
"Programming Language :: Python :: 3.12",
|
|
24
27
|
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
25
29
|
"Topic :: Office/Business :: Financial :: Accounting",
|
|
26
30
|
]
|
|
27
31
|
dependencies = [
|
|
28
32
|
"click>=8.1",
|
|
29
33
|
"requests>=2.31",
|
|
30
34
|
"tabulate>=0.9",
|
|
35
|
+
"tomli>=1.1.0; python_version < '3.11'",
|
|
31
36
|
]
|
|
32
37
|
|
|
33
38
|
[project.optional-dependencies]
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import math
|
|
6
7
|
import time
|
|
8
|
+
from datetime import timezone
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
7
10
|
from typing import Any
|
|
8
11
|
|
|
9
12
|
import click
|
|
@@ -14,6 +17,28 @@ logger = logging.getLogger(__name__)
|
|
|
14
17
|
API_BASE = "https://moneybird.com/api/v2"
|
|
15
18
|
|
|
16
19
|
|
|
20
|
+
def _rate_limit_delay(value: str | None) -> int:
|
|
21
|
+
if value is None:
|
|
22
|
+
return 60
|
|
23
|
+
try:
|
|
24
|
+
seconds = int(value)
|
|
25
|
+
except ValueError:
|
|
26
|
+
try:
|
|
27
|
+
date = parsedate_to_datetime(value)
|
|
28
|
+
if date.tzinfo is None:
|
|
29
|
+
date = date.replace(tzinfo=timezone.utc)
|
|
30
|
+
timestamp = date.timestamp()
|
|
31
|
+
except (TypeError, ValueError, OverflowError):
|
|
32
|
+
return 60
|
|
33
|
+
else:
|
|
34
|
+
if seconds < 0:
|
|
35
|
+
return 60
|
|
36
|
+
if seconds < 1_000_000_000:
|
|
37
|
+
return seconds
|
|
38
|
+
timestamp = seconds
|
|
39
|
+
return max(0, math.ceil(timestamp - time.time()))
|
|
40
|
+
|
|
41
|
+
|
|
17
42
|
class MoneybirdApiError(click.ClickException):
|
|
18
43
|
"""API error with HTTP status code context."""
|
|
19
44
|
|
|
@@ -51,14 +76,20 @@ class MoneybirdClient:
|
|
|
51
76
|
def _url(self, path: str) -> str:
|
|
52
77
|
return f"{self.base_url}/{path}.json"
|
|
53
78
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
def _retry_rate_limited(
|
|
80
|
+
self, response: requests.Response, allow_redirects: bool = True
|
|
81
|
+
) -> requests.Response:
|
|
82
|
+
while response.status_code == 429:
|
|
83
|
+
retry_after = _rate_limit_delay(response.headers.get("Retry-After"))
|
|
57
84
|
logger.warning("Rate limited. Retrying after %d seconds.", retry_after)
|
|
58
85
|
time.sleep(retry_after)
|
|
59
|
-
|
|
60
|
-
|
|
86
|
+
response = self.session.send(
|
|
87
|
+
response.request, allow_redirects=allow_redirects # type: ignore[arg-type]
|
|
61
88
|
)
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
def _handle_response(self, response: requests.Response) -> tuple[Any, requests.Response]:
|
|
92
|
+
response = self._retry_rate_limited(response)
|
|
62
93
|
|
|
63
94
|
if response.status_code == 204:
|
|
64
95
|
return None, response
|
|
@@ -123,7 +154,7 @@ class MoneybirdClient:
|
|
|
123
154
|
"""Sleep if rate limit is nearly exhausted."""
|
|
124
155
|
remaining = response.headers.get("RateLimit-Remaining")
|
|
125
156
|
if remaining is not None and int(remaining) <= 1:
|
|
126
|
-
reset =
|
|
157
|
+
reset = _rate_limit_delay(response.headers.get("RateLimit-Reset"))
|
|
127
158
|
if verbose:
|
|
128
159
|
click.echo(f"Rate limit nearly exhausted, waiting {reset}s.", err=True)
|
|
129
160
|
time.sleep(reset)
|
|
@@ -136,10 +167,12 @@ class MoneybirdClient:
|
|
|
136
167
|
url = self._url(path)
|
|
137
168
|
if self.verbose:
|
|
138
169
|
click.echo(f"{method} {url}", err=True)
|
|
139
|
-
response = self.
|
|
170
|
+
response = self._retry_rate_limited(
|
|
171
|
+
self.session.request(method, url, allow_redirects=False), allow_redirects=False
|
|
172
|
+
)
|
|
140
173
|
if response.is_redirect:
|
|
141
174
|
# Fetch without our session so the API token isn't sent to the file host.
|
|
142
|
-
response = requests.get(response.headers["Location"])
|
|
175
|
+
response = self._retry_rate_limited(requests.get(response.headers["Location"]))
|
|
143
176
|
if response.status_code >= 400:
|
|
144
177
|
self._handle_response(response)
|
|
145
178
|
return response.content
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
9
|
from moneybird_cli.client import MoneybirdClient
|
|
10
|
-
from moneybird_cli.config import load_config, save_config, set_administration, remove_administration
|
|
10
|
+
from moneybird_cli.config import complete_administration, load_config, save_config, set_administration, remove_administration
|
|
11
11
|
from moneybird_cli.main import pass_context, Context
|
|
12
12
|
|
|
13
13
|
|
|
@@ -51,7 +51,7 @@ def logout(ctx: Context) -> None:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
@click.command(name="default")
|
|
54
|
-
@click.argument("name")
|
|
54
|
+
@click.argument("name", shell_complete=complete_administration)
|
|
55
55
|
@pass_context
|
|
56
56
|
def set_default(ctx: Context, name: str) -> None:
|
|
57
57
|
"""Set the default administration.
|
|
@@ -81,6 +81,16 @@ def save_config(data: dict, path: Path | None = None) -> None:
|
|
|
81
81
|
config_path.write_text(_serialize_toml(data) + "\n")
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def complete_administration(ctx, param, incomplete: str) -> list[str]:
|
|
85
|
+
"""Shell completion callback for administration names."""
|
|
86
|
+
config_path = ctx.find_root().params.get("config_path")
|
|
87
|
+
try:
|
|
88
|
+
admins = load_config(config_path).get("administrations", {})
|
|
89
|
+
except Exception:
|
|
90
|
+
return []
|
|
91
|
+
return [name for name in admins if name.startswith(incomplete)]
|
|
92
|
+
|
|
93
|
+
|
|
84
94
|
def get_administration(config: dict, name: str | None = None) -> dict:
|
|
85
95
|
"""Get credentials for a named administration.
|
|
86
96
|
|
|
@@ -31,6 +31,7 @@ def _default_timezone() -> str:
|
|
|
31
31
|
from moneybird_cli.client import MoneybirdClient
|
|
32
32
|
from moneybird_cli.config import (
|
|
33
33
|
DEFAULT_CONFIG_PATH,
|
|
34
|
+
complete_administration,
|
|
34
35
|
get_administration,
|
|
35
36
|
load_config,
|
|
36
37
|
)
|
|
@@ -90,6 +91,7 @@ pass_context = click.make_pass_decorator(Context, ensure=True)
|
|
|
90
91
|
@click.option(
|
|
91
92
|
"--administration",
|
|
92
93
|
metavar="NAME",
|
|
94
|
+
shell_complete=complete_administration,
|
|
93
95
|
help="Administration name to use (from config).",
|
|
94
96
|
)
|
|
95
97
|
@click.option("--token", metavar="TOKEN", help="API token for authentication.")
|
|
@@ -142,6 +144,22 @@ def cli(
|
|
|
142
144
|
ctx.timezone = timezone
|
|
143
145
|
|
|
144
146
|
|
|
147
|
+
@cli.command()
|
|
148
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
|
|
149
|
+
def completion(shell: str) -> None:
|
|
150
|
+
"""Print the shell completion script for SHELL.
|
|
151
|
+
|
|
152
|
+
\b
|
|
153
|
+
bash: moneybird completion bash > ~/.local/share/bash-completion/completions/moneybird
|
|
154
|
+
zsh: moneybird completion zsh > "${fpath[1]}/_moneybird"
|
|
155
|
+
fish: moneybird completion fish > ~/.config/fish/completions/moneybird.fish
|
|
156
|
+
"""
|
|
157
|
+
from click.shell_completion import get_completion_class
|
|
158
|
+
|
|
159
|
+
comp_cls = get_completion_class(shell)
|
|
160
|
+
click.echo(comp_cls(cli, {}, "moneybird", "_MONEYBIRD_COMPLETE").source())
|
|
161
|
+
|
|
162
|
+
|
|
145
163
|
# Register command groups
|
|
146
164
|
from moneybird_cli.commands.login import login, logout, set_default # noqa: E402
|
|
147
165
|
from moneybird_cli.commands.contacts import contacts # noqa: E402
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moneybird-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Command-line interface for the Moneybird API
|
|
5
5
|
Author-email: Koert van der Veer <koert@ondergetekende.nl>
|
|
6
|
-
License
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Koert van der Veer
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
7
28
|
Project-URL: Homepage, https://github.com/ondergetekende/moneybird-cli
|
|
8
29
|
Project-URL: Repository, https://github.com/ondergetekende/moneybird-cli
|
|
9
30
|
Project-URL: Issues, https://github.com/ondergetekende/moneybird-cli/issues
|
|
@@ -13,16 +34,21 @@ Classifier: Environment :: Console
|
|
|
13
34
|
Classifier: Intended Audience :: End Users/Desktop
|
|
14
35
|
Classifier: Intended Audience :: System Administrators
|
|
15
36
|
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
40
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
41
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
42
|
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
44
|
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
|
-
Requires-Python: >=3.
|
|
45
|
+
Requires-Python: >=3.8
|
|
21
46
|
Description-Content-Type: text/markdown
|
|
22
47
|
License-File: LICENSE
|
|
23
48
|
Requires-Dist: click>=8.1
|
|
24
49
|
Requires-Dist: requests>=2.31
|
|
25
50
|
Requires-Dist: tabulate>=0.9
|
|
51
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
26
52
|
Provides-Extra: dev
|
|
27
53
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
54
|
Requires-Dist: responses>=0.23; extra == "dev"
|
|
@@ -61,11 +87,17 @@ See [Getting Started](docs/getting-started.md) for full setup instructions, outp
|
|
|
61
87
|
|
|
62
88
|
## Tech stack
|
|
63
89
|
|
|
64
|
-
- [Python](https://www.python.org/) >= 3.
|
|
90
|
+
- [Python](https://www.python.org/) >= 3.8
|
|
65
91
|
- [Click](https://click.palletsprojects.com/) for the CLI framework
|
|
66
92
|
- [Requests](https://requests.readthedocs.io/) for HTTP
|
|
67
93
|
- [Tabulate](https://pypi.org/project/tabulate/) for table formatting
|
|
68
94
|
|
|
95
|
+
## Testing
|
|
96
|
+
|
|
97
|
+
Install tox with `python -m pip install 'tox>=4'`, then run `tox` with Python 3.8 through 3.14 available. The matrix tests minimum and current runtime dependencies on each Python version, plus minimum development dependencies and package building on Python 3.8. Run an individual environment with `tox -e py38-min`.
|
|
98
|
+
|
|
99
|
+
Keep the minimum dependency pins in `tox.ini` in sync with `pyproject.toml` when changing dependency requirements.
|
|
100
|
+
|
|
69
101
|
## Releasing
|
|
70
102
|
|
|
71
103
|
Versions come from git tags (via `setuptools-scm`). Pushing a tag like `v1.2.3` runs the tests, builds, and publishes to PyPI:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
3
|
".github/workflows/release.yml",
|
|
4
|
+
".github/workflows/tests.yml",
|
|
4
5
|
".gitignore",
|
|
5
6
|
"LICENSE",
|
|
6
7
|
"README.md",
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"tests/test_commands/test_login.py",
|
|
54
55
|
"tests/test_commands/test_sales_invoices.py",
|
|
55
56
|
"tests/test_config.py",
|
|
56
|
-
"tests/test_output.py"
|
|
57
|
+
"tests/test_output.py",
|
|
58
|
+
"tox.ini"
|
|
57
59
|
]
|
|
58
60
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
|
+
import requests
|
|
6
7
|
import responses
|
|
7
8
|
|
|
8
9
|
from moneybird_cli.client import MoneybirdApiError, MoneybirdClient
|
|
@@ -159,6 +160,76 @@ def test_get_all_no_pause_when_remaining_high(client, monkeypatch):
|
|
|
159
160
|
assert slept == []
|
|
160
161
|
|
|
161
162
|
|
|
163
|
+
@pytest.mark.parametrize(
|
|
164
|
+
("retry_after", "expected"),
|
|
165
|
+
[
|
|
166
|
+
("1793177400", 227),
|
|
167
|
+
("Wed, 28 Oct 2026 08:50:00 GMT", 227),
|
|
168
|
+
("5", 5),
|
|
169
|
+
("1793177000", 0),
|
|
170
|
+
("invalid", 60),
|
|
171
|
+
("-1", 60),
|
|
172
|
+
(None, 60),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
@responses.activate
|
|
176
|
+
def test_retry_after_delay(client, monkeypatch, retry_after, expected):
|
|
177
|
+
slept = []
|
|
178
|
+
monkeypatch.setattr("moneybird_cli.client.time.time", lambda: 1793177173)
|
|
179
|
+
monkeypatch.setattr("moneybird_cli.client.time.sleep", slept.append)
|
|
180
|
+
headers = {} if retry_after is None else {"Retry-After": retry_after}
|
|
181
|
+
responses.add(responses.GET, f"{BASE}/contacts.json", status=429, headers=headers)
|
|
182
|
+
responses.add(responses.GET, f"{BASE}/contacts.json", json={"ok": True})
|
|
183
|
+
|
|
184
|
+
assert client.get("contacts") == {"ok": True}
|
|
185
|
+
assert slept == [expected]
|
|
186
|
+
assert len(responses.calls) == 2
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_rate_limit_reset_timestamp(monkeypatch):
|
|
190
|
+
slept = []
|
|
191
|
+
monkeypatch.setattr("moneybird_cli.client.time.time", lambda: 1793177173)
|
|
192
|
+
monkeypatch.setattr("moneybird_cli.client.time.sleep", slept.append)
|
|
193
|
+
response = requests.Response()
|
|
194
|
+
response.headers.update({"RateLimit-Remaining": "1", "RateLimit-Reset": "1793177400"})
|
|
195
|
+
|
|
196
|
+
MoneybirdClient._respect_rate_limit(response)
|
|
197
|
+
assert slept == [227]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@responses.activate
|
|
201
|
+
def test_download_retries_rate_limited_binary_response(client, monkeypatch):
|
|
202
|
+
slept = []
|
|
203
|
+
monkeypatch.setattr("moneybird_cli.client.time.sleep", slept.append)
|
|
204
|
+
responses.add(responses.GET, f"{BASE}/file.json", status=429, headers={"Retry-After": "5"})
|
|
205
|
+
responses.add(responses.GET, f"{BASE}/file.json", body=b"%PDF-1.0")
|
|
206
|
+
|
|
207
|
+
assert client.download("file") == b"%PDF-1.0"
|
|
208
|
+
assert slept == [5]
|
|
209
|
+
assert len(responses.calls) == 2
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@responses.activate
|
|
213
|
+
def test_download_retries_rate_limited_redirect(client, monkeypatch):
|
|
214
|
+
slept = []
|
|
215
|
+
monkeypatch.setattr("moneybird_cli.client.time.sleep", slept.append)
|
|
216
|
+
responses.add(responses.GET, f"{BASE}/file.json", status=429, headers={"Retry-After": "5"})
|
|
217
|
+
responses.add(
|
|
218
|
+
responses.GET, f"{BASE}/file.json", status=302,
|
|
219
|
+
headers={"Location": "https://files.example.com/file.pdf"},
|
|
220
|
+
)
|
|
221
|
+
responses.add(
|
|
222
|
+
responses.GET, "https://files.example.com/file.pdf", status=429,
|
|
223
|
+
headers={"Retry-After": "5"},
|
|
224
|
+
)
|
|
225
|
+
responses.add(responses.GET, "https://files.example.com/file.pdf", body=b"%PDF-1.0")
|
|
226
|
+
|
|
227
|
+
assert client.download("file") == b"%PDF-1.0"
|
|
228
|
+
assert slept == [5, 5]
|
|
229
|
+
assert len(responses.calls) == 4
|
|
230
|
+
assert all("Authorization" not in call.request.headers for call in responses.calls[2:])
|
|
231
|
+
|
|
232
|
+
|
|
162
233
|
@responses.activate
|
|
163
234
|
def test_timezone_header_sent():
|
|
164
235
|
client = MoneybirdClient(token="t", administration_id="123", timezone="Europe/Amsterdam")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[tox]
|
|
2
|
+
requires = tox>=4
|
|
3
|
+
env_list = py{38,39,310,311,312,313,314}-{min,latest}, py38-min-devmin, py38-build
|
|
4
|
+
|
|
5
|
+
[testenv]
|
|
6
|
+
extras = dev
|
|
7
|
+
deps =
|
|
8
|
+
min: click==8.1.0
|
|
9
|
+
min: requests==2.31.0
|
|
10
|
+
min: tabulate==0.9.0
|
|
11
|
+
min: tomli==1.1.0; python_version < '3.11'
|
|
12
|
+
min: pytest<9; python_version < '3.11'
|
|
13
|
+
devmin: pytest==7.0.0
|
|
14
|
+
devmin: responses==0.23.0
|
|
15
|
+
commands_pre =
|
|
16
|
+
min: python -c "import sys; from importlib.metadata import requires, version; from packaging.requirements import Requirement; from packaging.version import Version; expected = {'click': '8.1.0', 'requests': '2.31.0', 'tabulate': '0.9.0'}; expected.update((('tomli', '1.1.0'),) if sys.version_info < (3, 11) else ()); actual = dict((name, version(name)) for name in expected); assert actual == expected, actual; declared = tuple(map(Requirement, requires('moneybird-cli'))); assert all(any(req.name == name and any(spec.operator == '>=' and Version(spec.version) == Version(minimum) for spec in req.specifier) for req in declared) for name, minimum in expected.items()), 'Update tox minimum pins to match pyproject.toml'"
|
|
17
|
+
devmin: python -c "from importlib.metadata import requires, version; from packaging.requirements import Requirement; from packaging.version import Version; expected = {'pytest': '7.0.0', 'responses': '0.23.0'}; actual = dict((name, version(name)) for name in expected); assert actual == expected, actual; declared = tuple(map(Requirement, requires('moneybird-cli'))); assert all(any(req.name == name and any(spec.operator == '>=' and Version(spec.version) == Version(minimum) for spec in req.specifier) for req in declared) for name, minimum in expected.items()), 'Update tox minimum pins to match pyproject.toml'"
|
|
18
|
+
commands = pytest -q
|
|
19
|
+
|
|
20
|
+
[testenv:py38-build]
|
|
21
|
+
skip_install = true
|
|
22
|
+
deps = build
|
|
23
|
+
commands = python -m build --outdir {envtmpdir}/dist
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/external_sales_invoices.py
RENAMED
|
File without changes
|
{moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_accounts.py
RENAMED
|
File without changes
|
{moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_mutations.py
RENAMED
|
File without changes
|
{moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_statements.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/purchase_transactions.py
RENAMED
|
File without changes
|
{moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/recurring_sales_invoices.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|