rechnomat 0.1.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.
- rechnomat-0.1.0/LICENSE +21 -0
- rechnomat-0.1.0/MANIFEST.in +1 -0
- rechnomat-0.1.0/PKG-INFO +110 -0
- rechnomat-0.1.0/README.md +78 -0
- rechnomat-0.1.0/pyproject.toml +78 -0
- rechnomat-0.1.0/setup.cfg +4 -0
- rechnomat-0.1.0/src/rechnomat/__main__.py +34 -0
- rechnomat-0.1.0/src/rechnomat/cli.py +127 -0
- rechnomat-0.1.0/src/rechnomat/command/add_invoice.py +80 -0
- rechnomat-0.1.0/src/rechnomat/command/info.py +25 -0
- rechnomat-0.1.0/src/rechnomat/command/init.py +36 -0
- rechnomat-0.1.0/src/rechnomat/command/render_invoice.py +56 -0
- rechnomat-0.1.0/src/rechnomat/formatting.py +66 -0
- rechnomat-0.1.0/src/rechnomat/invoice_calc.py +64 -0
- rechnomat-0.1.0/src/rechnomat/invoice_html.py +75 -0
- rechnomat-0.1.0/src/rechnomat/invoice_numbering.py +52 -0
- rechnomat-0.1.0/src/rechnomat/invoice_pdf.py +65 -0
- rechnomat-0.1.0/src/rechnomat/letter_address.py +21 -0
- rechnomat-0.1.0/src/rechnomat/model.py +159 -0
- rechnomat-0.1.0/src/rechnomat/resources/backgrounds/letterhead.pdf +79 -0
- rechnomat-0.1.0/src/rechnomat/resources/customers/meier-gmbh.yml +12 -0
- rechnomat-0.1.0/src/rechnomat/resources/invoices/DE000001.yml +34 -0
- rechnomat-0.1.0/src/rechnomat/resources/seller/seller.yml +19 -0
- rechnomat-0.1.0/src/rechnomat/resources/templates/de/fonts/LiberationSans-Bold.ttf +0 -0
- rechnomat-0.1.0/src/rechnomat/resources/templates/de/fonts/LiberationSans-Regular.ttf +0 -0
- rechnomat-0.1.0/src/rechnomat/resources/templates/de/fonts/OFL.txt +102 -0
- rechnomat-0.1.0/src/rechnomat/resources/templates/de/template.css +180 -0
- rechnomat-0.1.0/src/rechnomat/resources/templates/de/template.html +114 -0
- rechnomat-0.1.0/src/rechnomat/theme.py +247 -0
- rechnomat-0.1.0/src/rechnomat/ui.py +230 -0
- rechnomat-0.1.0/src/rechnomat/yaml_io.py +12 -0
- rechnomat-0.1.0/src/rechnomat.egg-info/PKG-INFO +110 -0
- rechnomat-0.1.0/src/rechnomat.egg-info/SOURCES.txt +46 -0
- rechnomat-0.1.0/src/rechnomat.egg-info/dependency_links.txt +1 -0
- rechnomat-0.1.0/src/rechnomat.egg-info/entry_points.txt +2 -0
- rechnomat-0.1.0/src/rechnomat.egg-info/requires.txt +12 -0
- rechnomat-0.1.0/src/rechnomat.egg-info/top_level.txt +1 -0
- rechnomat-0.1.0/tests/test_add_invoice_command.py +112 -0
- rechnomat-0.1.0/tests/test_formatting.py +46 -0
- rechnomat-0.1.0/tests/test_init_command.py +64 -0
- rechnomat-0.1.0/tests/test_invoice_calc.py +86 -0
- rechnomat-0.1.0/tests/test_invoice_html.py +255 -0
- rechnomat-0.1.0/tests/test_invoice_numbering.py +64 -0
- rechnomat-0.1.0/tests/test_invoice_pdf.py +201 -0
- rechnomat-0.1.0/tests/test_letter_address.py +48 -0
- rechnomat-0.1.0/tests/test_model.py +165 -0
- rechnomat-0.1.0/tests/test_render_invoice_command.py +107 -0
- rechnomat-0.1.0/tests/test_yaml_io.py +17 -0
rechnomat-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Haberey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recursive-include src/rechnomat/resources *
|
rechnomat-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rechnomat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create invoices compliant with German E-Rechnung laws
|
|
5
|
+
Author-email: Sebastian Haberey <sebastian@haberey.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sebastianhaberey/rechnomat
|
|
8
|
+
Project-URL: Repository, https://github.com/sebastianhaberey/rechnomat
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/sebastianhaberey/rechnomat/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/sebastianhaberey/rechnomat/blob/main/CHANGELOG.md
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: click==8.4.2
|
|
21
|
+
Requires-Dist: cloup==3.1.0
|
|
22
|
+
Requires-Dist: jinja2==3.1.6
|
|
23
|
+
Requires-Dist: playwright==1.62.0
|
|
24
|
+
Requires-Dist: pydantic==2.13.4
|
|
25
|
+
Requires-Dist: pypdf==6.16.1
|
|
26
|
+
Requires-Dist: pyyaml==6.0.3
|
|
27
|
+
Requires-Dist: rich==15.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest==9.1.1; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff==0.16.3; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# rechnomat
|
|
34
|
+
|
|
35
|
+
Create invoices compliant with German E-Rechnung laws.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
git clone https://github.com/sebastianhaberey/rechnomat.git
|
|
41
|
+
cd rechnomat
|
|
42
|
+
pip install .
|
|
43
|
+
playwright install chromium
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Invoice PDFs are rendered by driving a headless Chromium instance (via
|
|
47
|
+
[Playwright](https://playwright.dev/python/)) over HTML/CSS templates. `playwright install
|
|
48
|
+
chromium` downloads the browser binary Playwright needs; it's a one-time, per-machine step, not a
|
|
49
|
+
pip package, and is required before `rechnomat render-invoice` will work.
|
|
50
|
+
|
|
51
|
+
### Getting started
|
|
52
|
+
|
|
53
|
+
The tool reads `backgrounds/`, `customers/`, `invoices/`, `seller/` and `templates/` from the
|
|
54
|
+
current directory, not from the installed package. Before running `rechnomat render-invoice`, run
|
|
55
|
+
`rechnomat init` inside whichever directory you run `rechnomat` from; it copies these five
|
|
56
|
+
folders, pre-filled with example data, from `src/rechnomat/resources/` into the current directory.
|
|
57
|
+
Folders that already exist are left untouched, so re-running `init` later only fills in whatever
|
|
58
|
+
is still missing; you are notified whether each folder was copied or skipped.
|
|
59
|
+
|
|
60
|
+
### System dependencies for headless Chromium (Linux only)
|
|
61
|
+
|
|
62
|
+
On Linux, Chromium also needs a handful of shared libraries that aren't installed by default.
|
|
63
|
+
macOS needs no extra step here - skip this section.
|
|
64
|
+
|
|
65
|
+
**Debian/Ubuntu:**
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
sudo playwright install-deps chromium
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Fedora:** `playwright install-deps` only knows how to drive `apt-get`, so on Fedora it fails
|
|
72
|
+
outright (`spawn apt-get ENOENT`). Install the equivalent packages with `dnf` instead:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
sudo dnf install -y atk at-spi2-atk at-spi2-core cups-libs libXcomposite libXdamage libXfixes
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If `rechnomat render-invoice` fails with "Host system is missing dependencies to run browsers",
|
|
79
|
+
run `ldd` against the downloaded Chromium binary to see exactly which `.so` is missing:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
ldd ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome | grep "not found"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Usage
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
rechnomat --help
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
For an editable install with test and lint tooling:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
pip install -e '.[dev]'
|
|
97
|
+
playwright install chromium
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tests use the example data and templates bundled in `src/rechnomat/resources/` directly.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
pytest
|
|
104
|
+
ruff check .
|
|
105
|
+
ruff format --check .
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rechnomat
|
|
2
|
+
|
|
3
|
+
Create invoices compliant with German E-Rechnung laws.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
git clone https://github.com/sebastianhaberey/rechnomat.git
|
|
9
|
+
cd rechnomat
|
|
10
|
+
pip install .
|
|
11
|
+
playwright install chromium
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Invoice PDFs are rendered by driving a headless Chromium instance (via
|
|
15
|
+
[Playwright](https://playwright.dev/python/)) over HTML/CSS templates. `playwright install
|
|
16
|
+
chromium` downloads the browser binary Playwright needs; it's a one-time, per-machine step, not a
|
|
17
|
+
pip package, and is required before `rechnomat render-invoice` will work.
|
|
18
|
+
|
|
19
|
+
### Getting started
|
|
20
|
+
|
|
21
|
+
The tool reads `backgrounds/`, `customers/`, `invoices/`, `seller/` and `templates/` from the
|
|
22
|
+
current directory, not from the installed package. Before running `rechnomat render-invoice`, run
|
|
23
|
+
`rechnomat init` inside whichever directory you run `rechnomat` from; it copies these five
|
|
24
|
+
folders, pre-filled with example data, from `src/rechnomat/resources/` into the current directory.
|
|
25
|
+
Folders that already exist are left untouched, so re-running `init` later only fills in whatever
|
|
26
|
+
is still missing; you are notified whether each folder was copied or skipped.
|
|
27
|
+
|
|
28
|
+
### System dependencies for headless Chromium (Linux only)
|
|
29
|
+
|
|
30
|
+
On Linux, Chromium also needs a handful of shared libraries that aren't installed by default.
|
|
31
|
+
macOS needs no extra step here - skip this section.
|
|
32
|
+
|
|
33
|
+
**Debian/Ubuntu:**
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
sudo playwright install-deps chromium
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Fedora:** `playwright install-deps` only knows how to drive `apt-get`, so on Fedora it fails
|
|
40
|
+
outright (`spawn apt-get ENOENT`). Install the equivalent packages with `dnf` instead:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
sudo dnf install -y atk at-spi2-atk at-spi2-core cups-libs libXcomposite libXdamage libXfixes
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If `rechnomat render-invoice` fails with "Host system is missing dependencies to run browsers",
|
|
47
|
+
run `ldd` against the downloaded Chromium binary to see exactly which `.so` is missing:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
ldd ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome | grep "not found"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
rechnomat --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
For an editable install with test and lint tooling:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pip install -e '.[dev]'
|
|
65
|
+
playwright install chromium
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tests use the example data and templates bundled in `src/rechnomat/resources/` directly.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
pytest
|
|
72
|
+
ruff check .
|
|
73
|
+
ruff format --check .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rechnomat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Create invoices compliant with German E-Rechnung laws"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
|
|
12
|
+
license = "MIT"
|
|
13
|
+
|
|
14
|
+
authors = [
|
|
15
|
+
{ name = "Sebastian Haberey", email = "sebastian@haberey.com" }
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"click==8.4.2",
|
|
20
|
+
"cloup==3.1.0",
|
|
21
|
+
"jinja2==3.1.6",
|
|
22
|
+
"playwright==1.62.0",
|
|
23
|
+
"pydantic==2.13.4",
|
|
24
|
+
"pypdf==6.16.1",
|
|
25
|
+
"pyyaml==6.0.3",
|
|
26
|
+
"rich==15.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
classifiers = [
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Operating System :: MacOS :: MacOS X",
|
|
34
|
+
"Operating System :: POSIX :: Linux",
|
|
35
|
+
"Environment :: Console",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/sebastianhaberey/rechnomat"
|
|
40
|
+
Repository = "https://github.com/sebastianhaberey/rechnomat"
|
|
41
|
+
"Bug Tracker" = "https://github.com/sebastianhaberey/rechnomat/issues"
|
|
42
|
+
Changelog = "https://github.com/sebastianhaberey/rechnomat/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
rechnomat = "rechnomat.__main__:main"
|
|
46
|
+
|
|
47
|
+
[project.optional-dependencies]
|
|
48
|
+
dev = [
|
|
49
|
+
"pytest==9.1.1",
|
|
50
|
+
"ruff==0.16.3",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools]
|
|
54
|
+
package-dir = { "" = "src" }
|
|
55
|
+
include-package-data = true
|
|
56
|
+
|
|
57
|
+
[tool.setuptools.packages.find]
|
|
58
|
+
where = ["src"]
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.package-data]
|
|
61
|
+
rechnomat = ["resources/**/*"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 120
|
|
65
|
+
target-version = "py314"
|
|
66
|
+
src = ["src", "tools"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint]
|
|
69
|
+
select = [
|
|
70
|
+
"E", # Pycodestyle (basic errors)
|
|
71
|
+
"F", # Pyflakes (unused imports, etc.)
|
|
72
|
+
"W", # Warnings
|
|
73
|
+
"I", # Import sorting (isort behavior)
|
|
74
|
+
"UP", # pyupgrade: e.g., prefer dict[str, int] over Dict[str, int]
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.format]
|
|
78
|
+
quote-style = "double"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import rechnomat.cli
|
|
5
|
+
from rechnomat import ui
|
|
6
|
+
from rechnomat.cli import cli
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
args = sys.argv[1:]
|
|
11
|
+
if render_newlines(args):
|
|
12
|
+
ui.newline()
|
|
13
|
+
try:
|
|
14
|
+
rechnomat_executable = Path(sys.argv[0])
|
|
15
|
+
cli.main(args, obj={"rechnomat_executable": rechnomat_executable})
|
|
16
|
+
except Exception as e:
|
|
17
|
+
if rechnomat.cli.DEBUG_MODE:
|
|
18
|
+
ui.stacktrace("Error while running rechnomat")
|
|
19
|
+
else:
|
|
20
|
+
ui.exception(e)
|
|
21
|
+
finally:
|
|
22
|
+
if render_newlines(args):
|
|
23
|
+
ui.newline()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_newlines(args: list[str]) -> bool:
|
|
27
|
+
"""Quick and dirty hack to evaluate if the two framing newlines should be rendered"""
|
|
28
|
+
if "--headless" in args:
|
|
29
|
+
return False # don't output anything in headless mode to avoid system mails
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import cloup
|
|
5
|
+
from click import help_option, pass_obj, version_option
|
|
6
|
+
from cloup import HelpFormatter, HelpTheme, Section, argument, group, option, pass_context
|
|
7
|
+
|
|
8
|
+
from rechnomat.command.add_invoice import AddInvoiceCommand
|
|
9
|
+
from rechnomat.command.info import InfoCommand
|
|
10
|
+
from rechnomat.command.init import InitCommand
|
|
11
|
+
from rechnomat.command.render_invoice import RenderInvoiceCommand
|
|
12
|
+
from rechnomat.model import Context, Paths
|
|
13
|
+
from rechnomat.theme import StyleId, theme
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
STYLES = theme().styles_cloup
|
|
18
|
+
|
|
19
|
+
CONTEXT_SETTINGS = cloup.Context.settings(
|
|
20
|
+
formatter_settings=HelpFormatter.settings(
|
|
21
|
+
theme=HelpTheme(
|
|
22
|
+
invoked_command=STYLES[StyleId.SECONDARY],
|
|
23
|
+
heading=STYLES[StyleId.HEADER],
|
|
24
|
+
col1=STYLES[StyleId.PRIMARY],
|
|
25
|
+
),
|
|
26
|
+
width=120,
|
|
27
|
+
max_width=120, # default is too low (80), but too high will yield really long help text lines
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
SECTION_MAIN = Section("Main commands")
|
|
32
|
+
SECTION_UTILITY = Section("Utility commands")
|
|
33
|
+
|
|
34
|
+
# TODO SH find a better way to pass debug flag to exception handling
|
|
35
|
+
DEBUG_MODE: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@group(help="Rechnomat - create invoices compliant with German E-Rechnung laws", context_settings=CONTEXT_SETTINGS)
|
|
39
|
+
@help_option(help="Show this page")
|
|
40
|
+
@version_option(prog_name="Rechnomat", help="Show version information", message="%(prog)s version %(version)s")
|
|
41
|
+
@option(
|
|
42
|
+
"--debug",
|
|
43
|
+
envvar="EASYBORG_DEBUG",
|
|
44
|
+
is_flag=True,
|
|
45
|
+
help="Enable debug mode",
|
|
46
|
+
)
|
|
47
|
+
@pass_context
|
|
48
|
+
def cli(
|
|
49
|
+
ctx: cloup.Context,
|
|
50
|
+
debug: bool,
|
|
51
|
+
) -> None:
|
|
52
|
+
# first, set DEBUG_MODE flag to enable stacktraces
|
|
53
|
+
global DEBUG_MODE
|
|
54
|
+
DEBUG_MODE = debug
|
|
55
|
+
|
|
56
|
+
ctx.ensure_object(dict)
|
|
57
|
+
|
|
58
|
+
paths = Paths(root=Path.cwd(), output_dir=Path.cwd())
|
|
59
|
+
rechnomat_executable = ctx.obj.pop("rechnomat_executable", None)
|
|
60
|
+
|
|
61
|
+
ctx.obj["context"] = Context(
|
|
62
|
+
debug=debug,
|
|
63
|
+
rechnomat_executable=rechnomat_executable,
|
|
64
|
+
paths=paths,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cli.command(section=SECTION_MAIN)
|
|
69
|
+
@option(
|
|
70
|
+
"-o",
|
|
71
|
+
"--overwrite",
|
|
72
|
+
is_flag=True,
|
|
73
|
+
help="Replace existing directories instead of skipping them",
|
|
74
|
+
)
|
|
75
|
+
@help_option(help="Show this message")
|
|
76
|
+
@pass_obj
|
|
77
|
+
def init(obj, overwrite):
|
|
78
|
+
"""
|
|
79
|
+
Copy the bundled example customers, invoices, seller and templates into the current directory
|
|
80
|
+
"""
|
|
81
|
+
command = InitCommand(overwrite=overwrite)
|
|
82
|
+
command.run(obj["context"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@cli.command(section=SECTION_MAIN, name="add-invoice")
|
|
86
|
+
@argument(
|
|
87
|
+
"customer-name",
|
|
88
|
+
required=False,
|
|
89
|
+
help="Customer to create the invoice for, references a Customer file by its filename stem (defaults to "
|
|
90
|
+
"whichever customer is on the most recent invoice)",
|
|
91
|
+
)
|
|
92
|
+
@help_option(help="Show this message")
|
|
93
|
+
@pass_obj
|
|
94
|
+
def add_invoice(obj, customer_name):
|
|
95
|
+
"""
|
|
96
|
+
Add a new invoice, copying the customer's most recent invoice, or the most recent invoice
|
|
97
|
+
overall, or the bundled example invoice - whichever is found first
|
|
98
|
+
"""
|
|
99
|
+
command = AddInvoiceCommand(customer_name=customer_name)
|
|
100
|
+
command.run(obj["context"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@cli.command(section=SECTION_MAIN, name="render-invoice")
|
|
104
|
+
@argument(
|
|
105
|
+
"invoice-number",
|
|
106
|
+
required=False,
|
|
107
|
+
help="Invoice number to render (defaults to the highest-numbered invoice in invoices/)",
|
|
108
|
+
)
|
|
109
|
+
@help_option(help="Show this message")
|
|
110
|
+
@pass_obj
|
|
111
|
+
def render_invoice(obj, invoice_number):
|
|
112
|
+
"""
|
|
113
|
+
Render an invoice as a PDF
|
|
114
|
+
"""
|
|
115
|
+
command = RenderInvoiceCommand(invoice_number=invoice_number)
|
|
116
|
+
command.run(obj["context"])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@cli.command(section=SECTION_UTILITY)
|
|
120
|
+
@help_option(help="Show this message")
|
|
121
|
+
@pass_obj
|
|
122
|
+
def info(obj):
|
|
123
|
+
"""
|
|
124
|
+
Show current configuration, paths etc.
|
|
125
|
+
"""
|
|
126
|
+
command = InfoCommand()
|
|
127
|
+
command.run(obj["context"])
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from rechnomat import ui
|
|
7
|
+
from rechnomat.command.init import RESOURCES_DIR
|
|
8
|
+
from rechnomat.invoice_numbering import (
|
|
9
|
+
find_highest_invoice_number,
|
|
10
|
+
find_highest_matching_invoice_number,
|
|
11
|
+
increment_invoice_number,
|
|
12
|
+
)
|
|
13
|
+
from rechnomat.model import Context
|
|
14
|
+
|
|
15
|
+
EXAMPLE_INVOICES_DIR = RESOURCES_DIR / "invoices"
|
|
16
|
+
|
|
17
|
+
_CUSTOMER_FIELD_PATTERN = re.compile(r'^(customer:\s*)"[^"]*"', re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AddInvoiceCommand:
|
|
21
|
+
def __init__(self, *, customer_name: str | None = None) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.customer_name = customer_name
|
|
24
|
+
|
|
25
|
+
def run(self, context: Context) -> None:
|
|
26
|
+
paths = context.paths
|
|
27
|
+
|
|
28
|
+
if self.customer_name is not None:
|
|
29
|
+
customer_file = paths.customer_file(self.customer_name)
|
|
30
|
+
if not customer_file.exists():
|
|
31
|
+
raise RuntimeError(f"Customer file not found: {customer_file}")
|
|
32
|
+
|
|
33
|
+
invoices_dir = paths.invoices_dir
|
|
34
|
+
customer_invoice_number = (
|
|
35
|
+
find_highest_matching_invoice_number(invoices_dir, self._belongs_to_customer)
|
|
36
|
+
if self.customer_name is not None
|
|
37
|
+
else None
|
|
38
|
+
)
|
|
39
|
+
highest_invoice_number = find_highest_invoice_number(invoices_dir)
|
|
40
|
+
|
|
41
|
+
if customer_invoice_number is not None:
|
|
42
|
+
source_file = paths.invoice_file(customer_invoice_number)
|
|
43
|
+
rewrite_customer = False
|
|
44
|
+
elif highest_invoice_number is not None:
|
|
45
|
+
source_file = paths.invoice_file(highest_invoice_number)
|
|
46
|
+
rewrite_customer = self.customer_name is not None
|
|
47
|
+
else:
|
|
48
|
+
example_number = find_highest_invoice_number(EXAMPLE_INVOICES_DIR)
|
|
49
|
+
if example_number is None:
|
|
50
|
+
raise RuntimeError(f"No example invoice found in: {EXAMPLE_INVOICES_DIR}")
|
|
51
|
+
source_file = EXAMPLE_INVOICES_DIR / f"{example_number}.yml"
|
|
52
|
+
rewrite_customer = self.customer_name is not None
|
|
53
|
+
|
|
54
|
+
if not source_file.exists():
|
|
55
|
+
raise RuntimeError(f"Invoice file not found: {source_file}")
|
|
56
|
+
|
|
57
|
+
new_invoice_number = (
|
|
58
|
+
increment_invoice_number(highest_invoice_number) if highest_invoice_number is not None else source_file.stem
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
target_file = paths.invoice_file(new_invoice_number)
|
|
62
|
+
|
|
63
|
+
content = source_file.read_text(encoding="utf-8")
|
|
64
|
+
if rewrite_customer:
|
|
65
|
+
content = self._with_customer(content)
|
|
66
|
+
|
|
67
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
target_file.write_text(content, encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
ui.success("Added invoice", str(target_file))
|
|
71
|
+
|
|
72
|
+
def _belongs_to_customer(self, invoice_file: Path) -> bool:
|
|
73
|
+
data = yaml.safe_load(invoice_file.read_text(encoding="utf-8"))
|
|
74
|
+
return isinstance(data, dict) and data.get("customer") == self.customer_name
|
|
75
|
+
|
|
76
|
+
def _with_customer(self, content: str) -> str:
|
|
77
|
+
new_content, count = _CUSTOMER_FIELD_PATTERN.subn(rf'\1"{self.customer_name}"', content, count=1)
|
|
78
|
+
if count == 0:
|
|
79
|
+
raise RuntimeError("Source invoice file has no 'customer' field to replace")
|
|
80
|
+
return new_content
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from rechnomat import ui
|
|
2
|
+
from rechnomat.model import Context
|
|
3
|
+
from rechnomat.theme import StyleId, theme
|
|
4
|
+
|
|
5
|
+
STYLES = theme().styles
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InfoCommand:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
super().__init__()
|
|
11
|
+
|
|
12
|
+
def run(self, context: Context) -> None:
|
|
13
|
+
rows = [
|
|
14
|
+
("Executable", context.rechnomat_executable),
|
|
15
|
+
("Customers directory", context.paths.customers_dir),
|
|
16
|
+
("Invoices directory", context.paths.invoices_dir),
|
|
17
|
+
("Seller directory", context.paths.seller_dir),
|
|
18
|
+
("Templates directory", context.paths.templates_dir),
|
|
19
|
+
("Output directory", context.paths.output_dir),
|
|
20
|
+
]
|
|
21
|
+
ui.header("Context", first=True)
|
|
22
|
+
ui.table(
|
|
23
|
+
rows,
|
|
24
|
+
column_colors=(STYLES[StyleId.PRIMARY], None),
|
|
25
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
from rechnomat import ui
|
|
5
|
+
from rechnomat.model import Context
|
|
6
|
+
|
|
7
|
+
RESOURCES_DIR = importlib.resources.files("rechnomat") / "resources"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InitCommand:
|
|
11
|
+
def __init__(self, overwrite: bool = False) -> None:
|
|
12
|
+
self.overwrite = overwrite
|
|
13
|
+
|
|
14
|
+
def run(self, context: Context) -> None:
|
|
15
|
+
paths = context.paths
|
|
16
|
+
resources = (
|
|
17
|
+
("backgrounds", paths.backgrounds_dir),
|
|
18
|
+
("customers", paths.customers_dir),
|
|
19
|
+
("invoices", paths.invoices_dir),
|
|
20
|
+
("seller", paths.seller_dir),
|
|
21
|
+
("templates", paths.templates_dir),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
for name, target_dir in resources:
|
|
25
|
+
replaced = False
|
|
26
|
+
|
|
27
|
+
if target_dir.exists():
|
|
28
|
+
if not self.overwrite:
|
|
29
|
+
ui.warn("Skipped", f"{target_dir} (already exists)")
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
shutil.rmtree(target_dir)
|
|
33
|
+
replaced = True
|
|
34
|
+
|
|
35
|
+
shutil.copytree(RESOURCES_DIR / name, target_dir)
|
|
36
|
+
ui.success("Replaced" if replaced else "Copied", str(target_dir))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from rechnomat import ui
|
|
2
|
+
from rechnomat.invoice_numbering import find_highest_invoice_number
|
|
3
|
+
from rechnomat.invoice_pdf import render_invoice_pdf
|
|
4
|
+
from rechnomat.model import Context, Customer, Invoice, Seller
|
|
5
|
+
from rechnomat.yaml_io import load_model
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RenderInvoiceCommand:
|
|
9
|
+
def __init__(self, *, invoice_number: str | None = None) -> None:
|
|
10
|
+
super().__init__()
|
|
11
|
+
self.invoice_number = invoice_number
|
|
12
|
+
|
|
13
|
+
def run(self, context: Context) -> None:
|
|
14
|
+
invoices_dir = context.paths.invoices_dir
|
|
15
|
+
|
|
16
|
+
invoice_number = self.invoice_number or find_highest_invoice_number(invoices_dir)
|
|
17
|
+
if invoice_number is None:
|
|
18
|
+
raise RuntimeError(f"No invoices found in: {invoices_dir}")
|
|
19
|
+
|
|
20
|
+
invoice_file = context.paths.invoice_file(invoice_number)
|
|
21
|
+
if not invoice_file.exists():
|
|
22
|
+
raise RuntimeError(f"Invoice file not found: {invoice_file}")
|
|
23
|
+
invoice = load_model(invoice_file, Invoice)
|
|
24
|
+
|
|
25
|
+
customer_file = context.paths.customer_file(invoice.customer)
|
|
26
|
+
if not customer_file.exists():
|
|
27
|
+
raise RuntimeError(f"Customer file not found: {customer_file}")
|
|
28
|
+
customer = load_model(customer_file, Customer)
|
|
29
|
+
|
|
30
|
+
seller_file = context.paths.seller_file
|
|
31
|
+
if not seller_file.exists():
|
|
32
|
+
raise RuntimeError(f"Seller file not found: {seller_file}")
|
|
33
|
+
seller = load_model(seller_file, Seller)
|
|
34
|
+
|
|
35
|
+
template_dir = context.paths.template_dir(invoice.layout.template)
|
|
36
|
+
if not template_dir.exists():
|
|
37
|
+
raise RuntimeError(f"Template directory not found: {template_dir}")
|
|
38
|
+
|
|
39
|
+
background_path = None
|
|
40
|
+
if invoice.layout.background is not None:
|
|
41
|
+
background_path = context.paths.background_file(invoice.layout.background)
|
|
42
|
+
if not background_path.exists():
|
|
43
|
+
raise RuntimeError(f"Background file not found: {background_path}")
|
|
44
|
+
|
|
45
|
+
target_file = context.paths.output_dir / f"{invoice_number}.pdf"
|
|
46
|
+
render_invoice_pdf(
|
|
47
|
+
invoice=invoice,
|
|
48
|
+
invoice_number=invoice_number,
|
|
49
|
+
customer=customer,
|
|
50
|
+
seller=seller,
|
|
51
|
+
output_path=target_file,
|
|
52
|
+
template_dir=template_dir,
|
|
53
|
+
background_path=background_path,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
ui.success("Rendered invoice PDF", str(target_file))
|