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.
Files changed (67) hide show
  1. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/.github/workflows/release.yml +5 -2
  2. moneybird_cli-0.2.2/.github/workflows/tests.yml +32 -0
  3. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/.gitignore +1 -0
  4. {moneybird_cli-0.2.0/src/moneybird_cli.egg-info → moneybird_cli-0.2.2}/PKG-INFO +36 -4
  5. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/README.md +7 -1
  6. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/getting-started.md +15 -0
  7. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/pyproject.toml +7 -2
  8. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/client.py +41 -8
  9. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/login.py +2 -2
  10. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/config.py +10 -0
  11. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/main.py +18 -0
  12. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2/src/moneybird_cli.egg-info}/PKG-INFO +36 -4
  13. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/SOURCES.txt +2 -0
  14. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/requires.txt +3 -0
  15. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/scm_file_list.json +3 -1
  16. moneybird_cli-0.2.2/src/moneybird_cli.egg-info/scm_version.json +8 -0
  17. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_client.py +71 -0
  18. moneybird_cli-0.2.2/tox.ini +23 -0
  19. moneybird_cli-0.2.0/src/moneybird_cli.egg-info/scm_version.json +0 -8
  20. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/LICENSE +0 -0
  21. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/contacts.md +0 -0
  22. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/documents.md +0 -0
  23. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/financial.md +0 -0
  24. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/invoicing.md +0 -0
  25. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/products-and-subscriptions.md +0 -0
  26. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/reports.md +0 -0
  27. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/settings.md +0 -0
  28. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/docs/time-tracking.md +0 -0
  29. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/setup.cfg +0 -0
  30. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/__init__.py +0 -0
  31. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/__init__.py +0 -0
  32. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/_common.py +0 -0
  33. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/administration.py +0 -0
  34. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/assets.py +0 -0
  35. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/contacts.py +0 -0
  36. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/documents.py +0 -0
  37. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/estimates.py +0 -0
  38. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/external_sales_invoices.py +0 -0
  39. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_accounts.py +0 -0
  40. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_mutations.py +0 -0
  41. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/financial_statements.py +0 -0
  42. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/ledger_accounts.py +0 -0
  43. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/payments.py +0 -0
  44. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/products.py +0 -0
  45. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/projects.py +0 -0
  46. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/purchase_transactions.py +0 -0
  47. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/recurring_sales_invoices.py +0 -0
  48. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/reports.py +0 -0
  49. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/sales_invoices.py +0 -0
  50. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/subscriptions.py +0 -0
  51. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/task_lists.py +0 -0
  52. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/tax_rates.py +0 -0
  53. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/time_entries.py +0 -0
  54. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/commands/webhooks.py +0 -0
  55. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli/output.py +0 -0
  56. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/dependency_links.txt +0 -0
  57. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/entry_points.txt +0 -0
  58. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/src/moneybird_cli.egg-info/top_level.txt +0 -0
  59. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/__init__.py +0 -0
  60. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/conftest.py +0 -0
  61. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/__init__.py +0 -0
  62. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_contacts.py +0 -0
  63. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_financial_mutations.py +0 -0
  64. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_login.py +0 -0
  65. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_commands/test_sales_invoices.py +0 -0
  66. {moneybird_cli-0.2.0 → moneybird_cli-0.2.2}/tests/test_config.py +0 -0
  67. {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 -e ".[dev]" build
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}"
@@ -3,3 +3,4 @@
3
3
  dist/
4
4
  build/
5
5
  .venv/
6
+ .tox/
@@ -1,9 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moneybird-cli
3
- Version: 0.2.0
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-Expression: MIT
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.11
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.11
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.11
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"
11
- license = "MIT"
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 _handle_response(self, response: requests.Response) -> tuple[Any, requests.Response]:
55
- if response.status_code == 429:
56
- retry_after = int(response.headers.get("Retry-After", "60"))
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
- return self._handle_response(
60
- self.session.send(response.request) # type: ignore[arg-type]
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 = int(response.headers.get("RateLimit-Reset", "60"))
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.session.request(method, url, allow_redirects=False)
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.0
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-Expression: MIT
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.11
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.11
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:
@@ -2,7 +2,9 @@
2
2
  LICENSE
3
3
  README.md
4
4
  pyproject.toml
5
+ tox.ini
5
6
  .github/workflows/release.yml
7
+ .github/workflows/tests.yml
6
8
  docs/contacts.md
7
9
  docs/documents.md
8
10
  docs/financial.md
@@ -2,6 +2,9 @@ click>=8.1
2
2
  requests>=2.31
3
3
  tabulate>=0.9
4
4
 
5
+ [:python_version < "3.11"]
6
+ tomli>=1.1.0
7
+
5
8
  [dev]
6
9
  pytest>=7.0
7
10
  responses>=0.23
@@ -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
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.2",
3
+ "distance": 0,
4
+ "node": "gbee3d7db6c8b0f224073244b54fcf84c481291c8",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-29"
8
+ }
@@ -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
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.2.0",
3
- "distance": 0,
4
- "node": "g31cec8f0eebc41674cd838608923a7fe6379070f",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-27"
8
- }
File without changes
File without changes