├── .github
└── workflows
│ └── py.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.md
├── a38
├── __init__.py
├── builder.py
├── codec.py
├── consts.py
├── crypto.py
├── diff.py
├── fattura.py
├── fattura_semplificata.py
├── fields.py
├── models.py
├── render.py
├── traversal.py
├── trustedlist.py
└── validation.py
├── a38tool
├── a38tool.md
├── doc
├── .gitignore
└── README.md
├── document-a38
├── download-docs
├── publiccode.yml
├── requirements-devops.txt
├── requirements-lib.txt
├── setup.cfg
├── setup.py
├── stubs
├── __init__.pyi
└── dateutil
│ ├── __init__.pyi
│ └── parser.pyi
├── test-coverage
└── tests
├── data
├── dati_trasporto.xml
├── test.txt.p7m
└── unicode.xml
├── test_fattura.py
├── test_fields.py
├── test_models.py
└── test_p7m.py
/.github/workflows/py.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Python A38
3 |
4 | on:
5 | push:
6 | branches:
7 | - master
8 | pull_request:
9 | branches:
10 | - master
11 | # invoke the pipeline manually
12 | workflow_dispatch:
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-20.04
17 | steps:
18 | - uses: actions/checkout@v2
19 | - name: Work around Apt caching issues
20 | run: make ci-workaround
21 | - name: Install OS packages
22 | run: make install-os
23 | - name: Install Pip packages
24 | run: make install-py
25 | - name: Lint
26 | run: make lint
27 | - name: Run the tests
28 | run: make test
29 | - name: Install the package
30 | run: make install-package
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *.pyc
3 | /.mypy_cache
4 | /MANIFEST
5 | /.coverage
6 | /build
7 | /dist
8 | /htmlcov
9 | a38.egg-info/
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # New in version UNRELEASED
2 |
3 | * Fix tests for Python 3.12
4 |
5 | New in version 0.1.7
6 |
7 | * Allow ranges of decimal digits in some fields, to match specifications more
8 | closely
9 | * Added `allegati` subcommand to list and extract attachments. See #32
10 | * Allow more than one DatiRitenuta tag in DatiGeneraliDocumento, thanks
11 | @tschager, see #38
12 | * Bump minimum supported Python version to 3.11
13 |
14 | # New in version 0.1.6
15 |
16 | * Generate `dati_riepilogo` with properly set `natura` (#27)
17 | * Ignore non-significant digits when computing differences between Decimal fields
18 | * a38tool diff: return exit code 0 if there are no differences
19 | * Change Prezzo Unitario decimals precision to 3 digits, thanks @matteorizzello
20 | * Fixed a rounding issue (#35), thanks @tschager
21 | * Updated signature in test certificate
22 |
23 | # New in version 0.1.5
24 |
25 | * Added to `a38.codec` has a basic implementation of interactive editing in a
26 | text editor
27 |
28 | # New in version 0.1.4
29 |
30 | * When a Model instance is required, allow to pass a dict matching the Model
31 | fields instead
32 | * `natura_iva` is now from 2 to 4 characters long (#18)
33 | * Added a38.consts module with constants for common enumerations (#18)
34 | * Added `DettaglioLinee.autofill_prezzo_totale`
35 | * Export `a38.fattura.$MODEL` models as `a38.$MODEL`
36 | * Implemented `a38tool yaml` to export in YAML format
37 | * Implemented loading from YAML and JSON as if they were XML
38 | * Implemented `a38tool edit` to open a fattura in a text editor using YAML or
39 | Python formats (#22)
40 | * Use UTF-8 encoding and include xml declaration when writing XML from a38tool
41 | (#19)
42 | * New module `a38.codec`, with functions to load and save from/to all supported
43 | formats
44 | * Use defusedxml for parsing if available (#24)
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include MANIFEST.in
2 | include LICENSE
3 | include README.md
4 | include doc/README.md
5 | include tests/*.py
6 | include tests/data/*
7 | include stubs/*.pyi
8 | include stubs/dateutil/*.pyi
9 | include download-docs
10 | include document-a38
11 | include test-coverage
12 | include requirements-lib.txt
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | ci-workaround:
2 | sudo sed -i 's/azure\.//' /etc/apt/sources.list
3 | sudo apt-get -o Acquire::Retries=5 update
4 |
5 | install-os:
6 | sudo apt-get -o Acquire::Retries=5 install \
7 | openssl \
8 | wkhtmltopdf \
9 | eatmydata \
10 | python3-nose2
11 |
12 | install-py:
13 | pip install -r requirements-lib.txt
14 | pip install -r requirements-devops.txt
15 |
16 | test:
17 | sh test-coverage
18 |
19 | install-package:
20 | pip install .
21 |
22 | clean:
23 | rm --recursive --force \
24 | $(PWD)/build \
25 | $(PWD)/dist \
26 | $(PWD)/htmlcov \
27 | $(PWD)/a38.egg-info \
28 | $(PWD)/.coverage
29 |
30 | lint:
31 | isort \
32 | --check \
33 | $(PWD)/a38 \
34 | $(PWD)/tests \
35 | setup.py
36 | flake8 \
37 | --ignore=E126,E203,E501,W503 \
38 | --max-line-length 120 \
39 | --indent-size 4 \
40 | --jobs=8 \
41 | $(PWD)/a38 \
42 | $(PWD)/tests \
43 | setup.py
44 | bandit \
45 | --recursive \
46 | --number=3 \
47 | -lll \
48 | -iii \
49 | $(PWD)/a38 \
50 | $(PWD)/tests \
51 | setup.py
52 |
53 | lint-dev:
54 | isort \
55 | --atomic \
56 | $(PWD)/a38 \
57 | $(PWD)/tests \
58 | setup.py
59 | $(eval PIP_DEPS=$(shell awk '{printf("%s,",$$1)}' requirements-lib.txt | sed '$$s/,$$//'))
60 | autoflake \
61 | --imports=$(PIP_DEPS) \
62 | --recursive \
63 | --in-place \
64 | --remove-unused-variables \
65 | $(PWD)/a38 \
66 | $(PWD)/tests \
67 | setup.py
68 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Python A38
2 |
3 | 
4 |
5 | Library to generate Italian Fattura Elettronica from Python.
6 |
7 | This library implements a declarative data model similar to Django models, that
8 | is designed to describe, validate, serialize and parse Italian Fattura
9 | Elettronica data.
10 |
11 | Only part of the specification is implemented, with more added as needs will
12 | arise. You are welcome to implement the missing pieces you need and send a pull
13 | request: the idea is to have a good, free (as in freedom) library to make
14 | billing in Italy with Python easier for everyone.
15 |
16 | The library can generate various kinds of fatture that pass validation, and can
17 | parse all the example XML files distributed by
18 | [fatturapa.gov.it](https://www.fatturapa.gov.it/it/lafatturapa/esempi/)
19 |
20 |
21 | ## Dependencies
22 |
23 | Required: dateutil, pytz, asn1crypto, and the python3 standard library.
24 |
25 | Optional:
26 | * yapf for formatting `a38tool python` output
27 | * lxml for rendering to HTML
28 | * the wkhtmltopdf command for rendering to PDF
29 | * requests for downloading CA certificates for signature verification
30 |
31 |
32 | ## `a38tool` script
33 |
34 | A simple command line wrapper to the library functions is available as `a38tool`:
35 |
36 | ```text
37 | $ a38tool --help
38 | usage: a38tool [-h] [--verbose] [--debug]
39 | {json,xml,python,diff,validate,html,pdf,update_capath} ...
40 |
41 | Handle fattura elettronica files
42 |
43 | positional arguments:
44 | {json,xml,python,diff,validate,html,pdf,update_capath}
45 | actions
46 | json output a fattura in JSON
47 | xml output a fattura in XML
48 | python output a fattura as Python code
49 | diff show the difference between two fatture
50 | validate validate the contents of a fattura
51 | html render a Fattura as HTML using a .xslt stylesheet
52 | pdf render a Fattura as PDF using a .xslt stylesheet
53 | update_capath create/update an openssl CApath with CA certificates
54 | that can be used to validate digital signatures
55 |
56 | optional arguments:
57 | -h, --help show this help message and exit
58 | --verbose, -v verbose output
59 | --debug debug output
60 | ```
61 |
62 | See [a38tool.md](a38tool.md) for more details.
63 |
64 |
65 |
66 | ## Example code
67 |
68 | ```py
69 | import a38
70 | from a38.validation import Validation
71 | import datetime
72 | import sys
73 |
74 | cedente_prestatore = a38.CedentePrestatore(
75 | a38.DatiAnagraficiCedentePrestatore(
76 | a38.IdFiscaleIVA("IT", "01234567890"),
77 | codice_fiscale="NTNBLN22C23A123U",
78 | anagrafica=a38.Anagrafica(denominazione="Test User"),
79 | regime_fiscale="RF01",
80 | ),
81 | a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"),
82 | iscrizione_rea=a38.IscrizioneREA(
83 | ufficio="FI",
84 | numero_rea="123456",
85 | stato_liquidazione="LN",
86 | ),
87 | contatti=a38.Contatti(email="local_part@pec_domain.it"),
88 | )
89 |
90 | cessionario_committente = a38.CessionarioCommittente(
91 | a38.DatiAnagraficiCessionarioCommittente(
92 | a38.IdFiscaleIVA("IT", "76543210987"),
93 | anagrafica=a38.Anagrafica(denominazione="A Company SRL"),
94 | ),
95 | a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"),
96 | )
97 |
98 | bill_number = 1
99 |
100 | f = a38.FatturaPrivati12()
101 | f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561")
102 | f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF"
103 | f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore
104 | f.fattura_elettronica_header.cessionario_committente = cessionario_committente
105 |
106 | body = f.fattura_elettronica_body[0]
107 | body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento(
108 | tipo_documento="TD01",
109 | divisa="EUR",
110 | data=datetime.date.today(),
111 | numero=bill_number,
112 | causale=["Test billing"],
113 | )
114 |
115 | body.dati_beni_servizi.add_dettaglio_linee(
116 | descrizione="Test item", quantita=2, unita_misura="kg",
117 | prezzo_unitario="25.50", aliquota_iva="22.00")
118 |
119 | body.dati_beni_servizi.add_dettaglio_linee(
120 | descrizione="Other item", quantita=1, unita_misura="kg",
121 | prezzo_unitario="15.50", aliquota_iva="22.00")
122 |
123 | body.dati_beni_servizi.build_dati_riepilogo()
124 | body.build_importo_totale_documento()
125 |
126 | res = Validation()
127 | f.validate(res)
128 | if res.warnings:
129 | for w in res.warnings:
130 | print(str(w), file=sys.stderr)
131 | if res.errors:
132 | for e in res.errors:
133 | print(str(e), file=sys.stderr)
134 |
135 | filename = "{}{}_{:05d}.xml".format(
136 | f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese,
137 | f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice,
138 | bill_number)
139 |
140 | tree = f.build_etree()
141 | with open(filename, "wb") as out:
142 | tree.write(out, encoding="utf-8", xml_declaration=True)
143 | ```
144 |
145 |
146 | # Digital signatures
147 |
148 | Digital signatures on Firma Elettronica are
149 | [CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures.
150 |
151 | openssl cal verify the signatures, but not yet generate them. A patch to sign
152 | with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0)
153 | but not yet released as of 2019-02-26.
154 |
155 | ## Downloading CA certificates
156 |
157 | CA certificates for validating digital certificates are
158 | [distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature).
159 | See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati).
160 |
161 | There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but
162 | apparently no way of getting a simple bundle of certificates useable by
163 | openssl.
164 |
165 | `a38tool` has basic features to download and parse CA certificate information,
166 | and maintain a CA certificate directory:
167 |
168 | ```
169 | a38tool update_capath certdir/ --remove-old
170 | ```
171 |
172 | No particular effort is made to validate the downloaded certificates, besides
173 | the standard HTTPS checks performed by the [requests
174 | library](http://docs.python-requests.org/en/master/).
175 |
176 | ## Verifying signed `.p7m` files
177 |
178 | Once you have a CA certificate directory, verifying signed p7m files is quite
179 | straightforward:
180 |
181 | ```
182 | openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/
183 | ```
184 |
185 |
186 | # Useful links
187 |
188 | XSLT stylesheets for displaying fatture:
189 |
190 | * From [fatturapa.gov.it](https://www.fatturapa.gov.it/),
191 | among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html)
192 | * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip)
193 |
194 |
195 | # Copyright
196 |
197 | Copyright 2019-2024 Truelite S.r.l.
198 |
199 | This software is released under the Apache License 2.0
200 |
--------------------------------------------------------------------------------
/a38/__init__.py:
--------------------------------------------------------------------------------
1 | from .fattura import * # noqa
2 |
--------------------------------------------------------------------------------
/a38/builder.py:
--------------------------------------------------------------------------------
1 | # This module builds XML trees but does not parse them, so it does not need
2 | # defusedxml
3 | import xml.etree.ElementTree as ET
4 | from contextlib import contextmanager
5 |
6 | try:
7 | import lxml.etree
8 | HAVE_LXML = True
9 | except ModuleNotFoundError:
10 | HAVE_LXML = False
11 |
12 |
13 | class Builder:
14 | def __init__(self, etreebuilder=None):
15 | if etreebuilder is None:
16 | etreebuilder = ET.TreeBuilder()
17 | self.etreebuilder = etreebuilder
18 | self.default_namespace = None
19 |
20 | def _decorate_tag_name(self, tag: str):
21 | if self.default_namespace is not None and not tag.startswith("{"):
22 | return "{" + self.default_namespace + "}" + tag
23 | return tag
24 |
25 | def add(self, tag: str, value: str, **attrs):
26 | tag = self._decorate_tag_name(tag)
27 | self.etreebuilder.start(tag, attrs)
28 | if value is not None:
29 | self.etreebuilder.data(value)
30 | self.etreebuilder.end(tag)
31 |
32 | @contextmanager
33 | def element(self, tag: str, **attrs):
34 | tag = self._decorate_tag_name(tag)
35 | self.etreebuilder.start(tag, attrs)
36 | yield self
37 | self.etreebuilder.end(tag)
38 |
39 | @contextmanager
40 | def override_default_namespace(self, ns):
41 | b = Builder(self.etreebuilder)
42 | b.default_namespace = ns
43 | yield b
44 |
45 | def get_tree(self):
46 | root = self.etreebuilder.close()
47 | return ET.ElementTree(root)
48 |
49 |
50 | if HAVE_LXML:
51 | class LXMLBuilder:
52 | def __init__(self, etreebuilder=None):
53 | if etreebuilder is None:
54 | etreebuilder = lxml.etree.TreeBuilder()
55 | self.etreebuilder = etreebuilder
56 | self.default_namespace = None
57 |
58 | def _decorate_tag_name(self, tag: str):
59 | if self.default_namespace is not None and not tag.startswith("{"):
60 | return "{" + self.default_namespace + "}" + tag
61 | return tag
62 |
63 | def add(self, tag: str, value: str, **attrs):
64 | tag = self._decorate_tag_name(tag)
65 | self.etreebuilder.start(tag, attrs)
66 | if value is not None:
67 | self.etreebuilder.data(value)
68 | self.etreebuilder.end(tag)
69 |
70 | @contextmanager
71 | def element(self, tag: str, **attrs):
72 | tag = self._decorate_tag_name(tag)
73 | self.etreebuilder.start(tag, attrs)
74 | yield self
75 | self.etreebuilder.end(tag)
76 |
77 | @contextmanager
78 | def override_default_namespace(self, ns):
79 | b = Builder(self.etreebuilder)
80 | b.default_namespace = ns
81 | yield b
82 |
83 | def get_tree(self):
84 | root = self.etreebuilder.close()
85 | return lxml.etree.ElementTree(root)
86 |
--------------------------------------------------------------------------------
/a38/codec.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import io
4 | import json
5 | import logging
6 | import os
7 | import subprocess
8 | import tempfile
9 |
10 | try:
11 | from defusedxml import ElementTree as ET
12 | except ModuleNotFoundError:
13 | import xml.etree.ElementTree as ET
14 |
15 | from typing import (Any, BinaryIO, Dict, List, Optional, Sequence, TextIO,
16 | Type, Union)
17 |
18 | try:
19 | import ruamel.yaml
20 | yaml = None
21 | except ModuleNotFoundError:
22 | ruamel = None
23 | try:
24 | import yaml
25 | except ModuleNotFoundError:
26 | yaml = None
27 |
28 | from . import crypto
29 | from .fattura import auto_from_dict, auto_from_etree
30 | from .models import Model
31 |
32 | log = logging.getLogger("codec")
33 |
34 | if ruamel is not None:
35 | def _load_yaml(fd: TextIO):
36 | yaml_loader = ruamel.yaml.YAML(typ="safe", pure=True)
37 | return yaml_loader.load(fd)
38 |
39 | def _write_yaml(data: Dict[str, Any], file: TextIO):
40 | yaml = ruamel.yaml.YAML(typ="safe")
41 | yaml.default_flow_style = False
42 | yaml.allow_unicode = True
43 | yaml.explicit_start = True
44 | yaml.dump(data, file)
45 | elif yaml is not None:
46 | def _load_yaml(fd: TextIO):
47 | return yaml.load(fd, Loader=yaml.CLoader)
48 |
49 | def _write_yaml(data: Dict[str, Any], file: TextIO):
50 | yaml.dump(
51 | data, stream=file, default_flow_style=False, sort_keys=False,
52 | allow_unicode=True, explicit_start=True, Dumper=yaml.CDumper)
53 | else:
54 | def _load_yaml(fd: TextIO):
55 | raise NotImplementedError("loading YAML requires ruamel.yaml or PyYAML to be installed")
56 |
57 | def _write_yaml(data: Dict[str, Any], file: TextIO):
58 | raise NotImplementedError("writing YAML requires ruamel.yaml or PyYAML to be installed")
59 |
60 |
61 | class Codec:
62 | """
63 | Base class for format-specific reading and writing of fatture
64 | """
65 | # If True, file objects are expected to be open in binary mode
66 | binary = False
67 |
68 | def load(
69 | self,
70 | pathname: str,
71 | model: Optional[Type[Model]]) -> Model:
72 | """
73 | Load a fattura from a file.
74 |
75 | If model is provided it will be used for loading, otherwise the Model
76 | type will be autodetected
77 | """
78 | raise NotImplementedError(f"{self.__class__.__name__}.load is not implemented")
79 |
80 | def write_file(self, f: Model, file: Union[TextIO, BinaryIO]):
81 | """
82 | Write a fattura to the given file deescriptor.
83 | """
84 | raise NotImplementedError(f"{self.__class__.__name__}.write_file is not implemented")
85 |
86 | def save(self, f: Model, pathname: str):
87 | """
88 | Write a fattura to the given file
89 | """
90 | with open(pathname, "wb" if self.binary else "wt") as fd:
91 | self.write_file(f, fd)
92 |
93 | def interactive_edit(self, f: Model) -> Optional[Model]:
94 | """
95 | Edit the given model in an interactive editor, using the format of this
96 | codec
97 | """
98 | with io.StringIO() as orig:
99 | self.write_file(f, orig)
100 | return self.edit_buffer(orig.getvalue(), model=f.__class__)
101 |
102 | def edit_buffer(self, buf: str, model: Optional[Type[Model]] = None) -> Optional[Model]:
103 | """
104 | Open an editor on buf and return the edited fattura.
105 |
106 | Return None if editing did not change the contents.
107 | """
108 | editor = os.environ.get("EDITOR", "sensible-editor")
109 |
110 | current = buf
111 | error = None
112 |
113 | while True:
114 | with tempfile.NamedTemporaryFile(
115 | mode="wt",
116 | suffix=f".{self.EXTENSIONS[0]}") as tf:
117 | # Write out the current buffer
118 | tf.write(current)
119 | if error is not None:
120 | tf.write(f"# ERROR: {error}")
121 | error = None
122 | tf.flush()
123 |
124 | # Run the editor on it
125 | subprocess.run([editor, tf.name], check=True)
126 |
127 | # Reopen by name in case the editor did not write on the same
128 | # inode
129 | with open(tf.name, "rt") as fd:
130 | lines = []
131 | for line in fd:
132 | if line.startswith("# ERROR: "):
133 | continue
134 | lines.append(line)
135 | edited = "".join(lines)
136 |
137 | if edited == current:
138 | return None
139 |
140 | try:
141 | return self.load(tf.name, model=model)
142 | except Exception as e:
143 | log.error("%s: cannot load edited file: %s", tf.name, e)
144 | error = str(e)
145 |
146 |
147 | class P7M(Codec):
148 | """
149 | P7M codec, that only supports loading
150 | """
151 | EXTENSIONS = ("p7m",)
152 |
153 | def load(
154 | self,
155 | pathname: str,
156 | model: Optional[Type[Model]] = None) -> Model:
157 | p7m = crypto.P7M(pathname)
158 | return p7m.get_fattura()
159 |
160 |
161 | class JSON(Codec):
162 | """
163 | JSON codec.
164 |
165 | `indent` represents the JSON structure indentation, and can be None to
166 | output everything in a single line.
167 |
168 | `end` is a string that gets appended to the JSON structure.
169 | """
170 | EXTENSIONS = ("json",)
171 |
172 | def __init__(self, indent: Optional[int] = 1, end="\n"):
173 | self.indent = indent
174 | self.end = end
175 |
176 | def load(
177 | self,
178 | pathname: str,
179 | model: Optional[Type[Model]] = None) -> Model:
180 | with open(pathname, "rt") as fd:
181 | data = json.load(fd)
182 | if model:
183 | return model(**data)
184 | else:
185 | return auto_from_dict(data)
186 |
187 | def write_file(self, f: Model, file: TextIO):
188 | json.dump(f.to_jsonable(), file, indent=self.indent)
189 | if self.end is not None:
190 | file.write(self.end)
191 |
192 |
193 | class YAML(Codec):
194 | """
195 | YAML codec
196 | """
197 | EXTENSIONS = ("yaml", "yml")
198 |
199 | def load(
200 | self,
201 | pathname: str,
202 | model: Optional[Type[Model]] = None) -> Model:
203 | with open(pathname, "rt") as fd:
204 | data = _load_yaml(fd)
205 | if model:
206 | return model(**data)
207 | else:
208 | return auto_from_dict(data)
209 |
210 | def write_file(self, f: Model, file: TextIO):
211 | _write_yaml(f.to_jsonable(), file)
212 |
213 |
214 | class Python(Codec):
215 | """
216 | Python codec.
217 |
218 | `namespace` defines what namespace is used to refer to `a38` models. `None`
219 | means use a default, `False` means not to use a namespace, a string defines
220 | which namespace to use.
221 |
222 | `unformatted` can be set to True to skip code formatting.
223 |
224 | The code will be written with just the expression to build the fattura.
225 |
226 | The code assumes `import datetime` and `from decimal import Decimal`.
227 |
228 | If loadable is True, the file is written as a Python source that
229 | creates a `fattura` variable with the fattura, with all the imports that
230 | are needed. This generates a python file that can be loaded with load().
231 |
232 | Note that loading Python fatture executes arbitrary Python code!
233 | """
234 | EXTENSIONS = ("py",)
235 |
236 | def __init__(
237 | self, namespace: Union[None, bool, str] = "a38",
238 | unformatted: bool = False,
239 | loadable: bool = False):
240 | self.namespace = namespace
241 | self.unformatted = unformatted
242 | self.loadable = loadable
243 |
244 | def load(
245 | self,
246 | pathname: str,
247 | model: Optional[Type[Model]] = None) -> Model:
248 | with open(pathname, "rt") as fd:
249 | code = compile(fd.read(), pathname, 'exec')
250 |
251 | loc = {}
252 | exec(code, {}, loc)
253 | return loc["fattura"]
254 |
255 | def write_file(self, f: Model, file: TextIO):
256 | code = f.to_python(namespace=self.namespace)
257 |
258 | if not self.unformatted:
259 | try:
260 | from yapf.yapflib import yapf_api
261 | except ModuleNotFoundError:
262 | pass
263 | else:
264 | code, changed = yapf_api.FormatCode(code)
265 |
266 | if self.loadable:
267 | print("import datetime", file=file)
268 | print("from decimal import Decimal", file=file)
269 | if self.namespace:
270 | print("import", self.namespace, file=file)
271 | elif self.namespace is False:
272 | print("from a38.fattura import *", file=file)
273 | else:
274 | print("import a38", file=file)
275 | print(file=file)
276 | print("fattura = ", file=file, end="")
277 | print(code, file=file)
278 |
279 |
280 | class XML(Codec):
281 | """
282 | XML codec
283 | """
284 | EXTENSIONS = ("xml",)
285 |
286 | binary = True
287 |
288 | def load(
289 | self,
290 | pathname: str,
291 | model: Optional[Type[Model]] = None) -> Model:
292 | tree = ET.parse(pathname)
293 | return auto_from_etree(tree.getroot())
294 |
295 | def write_file(self, f: Model, file: BinaryIO):
296 | tree = f.build_etree()
297 | tree.write(file, encoding="utf-8", xml_declaration=True)
298 | file.write(b"\n")
299 |
300 |
301 | class Codecs:
302 | """
303 | A collection of codecs
304 | """
305 | ALL_CODECS = (XML, P7M, JSON, YAML, Python)
306 |
307 | def __init__(
308 | self,
309 | include: Optional[Sequence[Type[Codec]]] = None,
310 | exclude: Optional[Sequence[Type[Codec]]] = (Python,)):
311 | """
312 | if `include` is not None, only codecs in that list are used.
313 |
314 | If `exclude` is not None, all codecs are used except the given one.
315 |
316 | If neither `include` nor `exclude` are None, all codecs are used.
317 |
318 | By default, `exclude` is not None but it is set to exclude Python.
319 | """
320 | self.codecs: List[Type[Codec]]
321 |
322 | if include is not None and exclude is not None:
323 | raise ValueError("include and exclude cannot both be set")
324 | elif include is not None:
325 | self.codecs = list(include)
326 | elif exclude is not None:
327 | self.codecs = [c for c in self.ALL_CODECS if c not in exclude]
328 | else:
329 | self.codecs = list(self.ALL_CODECS)
330 |
331 | def codec_from_filename(self, pathname: str) -> Type[Codec]:
332 | """
333 | Infer a Codec class from the extension of the file at `pathname`.
334 | """
335 | ext = pathname.rsplit(".", 1)[1].lower()
336 |
337 | for c in self.codecs:
338 | if ext in c.EXTENSIONS:
339 | return c
340 |
--------------------------------------------------------------------------------
/a38/consts.py:
--------------------------------------------------------------------------------
1 | # see table at page 26 for the PDF document
2 | # GUIDA ALLA COMPILAZIONE DELLE FATTURE ELETTRONICHE E DELL’ESTEROMETRO
3 | # from AdE (Agenzia Delle Entrate), version 1.6 - 2022/02/04
4 | # https://www.agenziaentrate.gov.it/portale/documents/20143/451259/Guida_compilazione-FE_2021_07_07.pdf/e6fcdd04-a7bd-e6f2-ced4-cac04403a768
5 | # see also:
6 | # - https://agenziaentrate.gov.it/portale/documents/20143/296703/Variazioni+alle+specifiche+tecniche+fatture+elettroniche2021-07-02.pdf # noqa
7 | # - https://www.agenziaentrate.gov.it/portale/web/guest/schede/comunicazioni/fatture-e-corrispettivi/faq-fe/risposte-alle-domande-piu-frequenti-categoria/compilazione-della-fattura-elettronica # noqa
8 | NATURA_IVA = (
9 | "N1",
10 | "N2",
11 | "N2.1", # non soggette ad IVA ai sensi degli artt. da 7 a 7-septies del D.P.R. n. 633/72
12 | "N2.2", # non soggette - altri casi
13 | "N3",
14 | "N3.1", # non imponibili - esportazioni
15 | "N3.2", # non imponibili - cessioni intracomunitarie
16 | "N3.3", # non imponibili - cessioni verso San Marino
17 | "N3.4", # non imponibili - operazioni assimilate alle cessioni all'esportazione
18 | "N3.5", # non imponibili - a seguito di dichiarazioni d'intento
19 | "N3.6", # non imponibili - altre operazioni
20 | "N4",
21 | "N5",
22 | "N6",
23 | "N6.1", # inversione contabile - cessione di rottami e altri materiali di recupero
24 | "N6.2", # inversione contabile – cessione di oro e argento ai sensi della
25 | # legge 7/2000 nonché di oreficeria usata ad OPO
26 | "N6.3", # inversione contabile - subappalto nel settore edile
27 | "N6.4", # inversione contabile - cessione di fabbricati
28 | "N6.5", # inversione contabile - cessione di telefoni cellulari
29 | "N6.6", # inversione contabile - cessione di prodotti elettronici
30 | "N6.7", # inversione contabile - prestazioni comparto edile e settori connessi
31 | "N6.8", # inversione contabile - operazioni settore energetico
32 | "N6.9", # inversione contabile - altri casi
33 | "N7",
34 | )
35 |
36 | # see pages 1 to 25 for the PDF document
37 | # GUIDA ALLA COMPILAZIONE DELLE FATTURE ELETTRONICHE E DELL’ESTEROMETRO
38 | # from AdE (Agenzia Delle Entrate), version 1.6 - 2022/02/04
39 | # https://www.agenziaentrate.gov.it/portale/documents/20143/451259/Guida_compilazione-FE_2021_07_07.pdf/e6fcdd04-a7bd-e6f2-ced4-cac04403a768
40 | TIPO_DOCUMENTO = (
41 | "TD01", # FATTURA
42 | "TD02", # ACCONTO/ANTICIPO SU FATTURA
43 | "TD03", # ACCONTO/ANTICIPO SU PARCELLA
44 | "TD04", # NOTA DI CREDITO
45 | "TD05", # NOTA DI DEBITO
46 | "TD06", # PARCELLA
47 | "TD07", # FATTURA SEMPLIFICATA
48 | "TD08", # NOTA DI CREDITO SEMPLIFICATA
49 | "TD09", # NOTA DI DEBITO SEMPLIFICATA
50 | "TD16", # INTEGRAZIONE FATTURA DA REVERSE CHARGE INTERNO
51 | "TD17", # INTEGRAZIONE/AUTOFATTURA PER ACQUISTO SERVIZI DALL'ESTERO
52 | "TD18", # INTEGRAZIONE PER ACQUISTO DI BENI INTRACOMUNITARI
53 | "TD19", # INTEGRAZIONE/AUTOFATTURA PER ACQUISTO DI BENI EX ART. 17 C.2 D.P.R. 633/72
54 | "TD20", # AUTOFATTURA PER REGOLARIZZAZIONE E INTEGRAZIONE DELLE FATTURE
55 | # (EX ART. 6 COMMI 8 E 9-BIS D. LGS. 471/97 O ART. 46 C.5 D.L. 331/93)
56 | "TD21", # AUTOFATTURA PER SPLAFONAMENTO
57 | "TD22", # ESTRAZIONE BENI DA DEPOSITO IVA
58 | "TD23", # ESTRAZIONE BENI DA DEPOSITO IVA CON VERSAMENTO DELL'IVA
59 | "TD24", # FATTURA DIFFERITA DI CUI ALL'ART. 21, COMMA 4, TERZO PERIODO, LETT. A), DEL D.P.R. N. 633/72
60 | "TD25", # FATTURA DIFFERITA DI CUI ALL'ART. 21, COMMA 4, TERZO PERIODO LETT. B), DEL D.P.R. N. 633/72
61 | "TD26", # CESSIONE DI BENI AMMORTIZZABILI E PER PASSAGGI INTERNI (EX ART. 36 D.P.R. 633/72)
62 | "TD27", # FATTURA PER AUTOCONSUMO O PER CESSIONI GRATUITE SENZA RIVALSA
63 | )
64 |
65 |
66 | # Copied from Documentazione valida a partire dal 1 ottobre 2020
67 | # Rappresentazione tabellare del tracciato fattura ordinaria - excel
68 | REGIME_FISCALE = (
69 | "RF01", # Ordinario
70 | "RF02", # Contribuenti minimi (art.1, c.96-117, L. 244/07)
71 | "RF04", # Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR 633/72)
72 | "RF05", # Vendita sali e tabacchi (art.74, c.1, DPR. 633/72)
73 | "RF06", # Commercio fiammiferi (art.74, c.1, DPR 633/72)
74 | "RF07", # Editoria (art.74, c.1, DPR 633/72)
75 | "RF08", # Gestione servizi telefonia pubblica (art.74, c.1, DPR 633/72)
76 | "RF09", # Rivendita documenti di trasporto pubblico e di sosta (art.74, c.1, DPR 633/72)
77 | "RF10", # Intrattenimenti, giochi e altre attività di cui alla tariffa
78 | # allegata al DPR 640/72 (art.74, c.6, DPR 633/72)
79 | "RF11", # Agenzie viaggi e turismo (art.74-ter, DPR 633/72)
80 | "RF12", # Agriturismo (art.5, c.2, L. 413/91)
81 | "RF13", # Vendite a domicilio (art.25-bis, c.6, DPR 600/73)
82 | "RF14", # Rivendita beni usati, oggetti d’arte, d’antiquariato o da collezione (art.36, DL 41/95)
83 | "RF15", # Agenzie di vendite all’asta di oggetti d’arte, antiquariato o da collezione (art.40-bis, DL 41/95)
84 | "RF16", # IVA per cassa P.A. (art.6, c.5, DPR 633/72)
85 | "RF17", # IVA per cassa (art. 32-bis, DL 83/2012)
86 | "RF18", # Altro
87 | "RF19", # Regime forfettario (art.1, c.54-89, L. 190/2014)
88 | )
89 |
90 | # Copied from Documentazione valida a partire dal 1 ottobre 2020
91 | # Rappresentazione tabellare del tracciato fattura ordinaria - excel
92 | TIPO_CASSA = (
93 | "TC01", # Cassa nazionale previdenza e assistenza avvocati e procuratori legali
94 | "TC02", # Cassa previdenza dottori commercialisti
95 | "TC03", # Cassa previdenza e assistenza geometri
96 | "TC04", # Cassa nazionale previdenza e assistenza ingegneri e architetti liberi professionisti
97 | "TC05", # Cassa nazionale del notariato
98 | "TC06", # Cassa nazionale previdenza e assistenza ragionieri e periti commerciali
99 | "TC07", # Ente nazionale assistenza agenti e rappresentanti di commercio (ENASARCO)
100 | "TC08", # Ente nazionale previdenza e assistenza consulenti del lavoro (ENPACL)
101 | "TC09", # Ente nazionale previdenza e assistenza medici (ENPAM)
102 | "TC10", # Ente nazionale previdenza e assistenza farmacisti (ENPAF)
103 | "TC11", # Ente nazionale previdenza e assistenza veterinari (ENPAV)
104 | "TC12", # Ente nazionale previdenza e assistenza impiegati dell'agricoltura (ENPAIA)
105 | "TC13", # Fondo previdenza impiegati imprese di spedizione e agenzie marittime
106 | "TC14", # Istituto nazionale previdenza giornalisti italiani (INPGI)
107 | "TC15", # Opera nazionale assistenza orfani sanitari italiani (ONAOSI)
108 | "TC16", # Cassa autonoma assistenza integrativa giornalisti italiani (CASAGIT)
109 | "TC17", # Ente previdenza periti industriali e periti industriali laureati (EPPI)
110 | "TC18", # Ente previdenza e assistenza pluricategoriale (EPAP)
111 | "TC19", # Ente nazionale previdenza e assistenza biologi (ENPAB)
112 | "TC20", # Ente nazionale previdenza e assistenza professione infermieristica (ENPAPI)
113 | "TC21", # Ente nazionale previdenza e assistenza psicologi (ENPAP)
114 | "TC22", # INPS
115 | )
116 |
117 | # Copied from Documentazione valida a partire dal 1 ottobre 2020
118 | # Rappresentazione tabellare del tracciato fattura ordinaria - excel
119 | MODALITA_PAGAMENTO = (
120 | "MP01", # contanti
121 | "MP02", # assegno
122 | "MP03", # assegno circolare
123 | "MP04", # contanti presso Tesoreria
124 | "MP05", # bonifico
125 | "MP06", # vaglia cambiario
126 | "MP07", # bollettino bancario
127 | "MP08", # carta di pagamento
128 | "MP09", # RID
129 | "MP10", # RID utenze
130 | "MP11", # RID veloce
131 | "MP12", # RIBA
132 | "MP13", # MAV
133 | "MP14", # quietanza erario
134 | "MP15", # giroconto su conti di contabilità speciale
135 | "MP16", # domiciliazione bancaria
136 | "MP17", # domiciliazione postale
137 | "MP18", # bollettino di c/c postale
138 | "MP19", # SEPA Direct Debit
139 | "MP20", # SEPA Direct Debit CORE
140 | "MP21", # SEPA Direct Debit B2B
141 | "MP22", # Trattenuta su somme già riscosse
142 | "MP23", # PagoPA
143 | )
144 |
145 | # Copied from Documentazione valida a partire dal 1 ottobre 2020
146 | # Rappresentazione tabellare del tracciato fattura ordinaria - excel
147 | TIPO_RITENUTA = (
148 | "RT01", # ritenuta persone fisiche
149 | "RT02", # ritenuta persone giuridiche
150 | "RT03", # contributo INPS
151 | "RT04", # contributo ENASARCO
152 | "RT05", # contributo ENPAM
153 | "RT06", # altro contributo previdenziale
154 | )
155 |
--------------------------------------------------------------------------------
/a38/crypto.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import binascii
3 | import datetime
4 | import io
5 | import subprocess
6 |
7 | try:
8 | from defusedxml import ElementTree as ET
9 | except ModuleNotFoundError:
10 | import xml.etree.ElementTree as ET
11 |
12 | from typing import BinaryIO, Union
13 |
14 | from asn1crypto.cms import ContentInfo
15 |
16 | from . import fattura as a38
17 |
18 |
19 | class SignatureVerificationError(Exception):
20 | pass
21 |
22 |
23 | class InvalidSignatureError(SignatureVerificationError):
24 | pass
25 |
26 |
27 | class SignerCertificateError(SignatureVerificationError):
28 | pass
29 |
30 |
31 | class P7M:
32 | """
33 | Parse a Fattura Elettronica encoded as a .p7m file
34 | """
35 | def __init__(self, data: Union[str, bytes, BinaryIO]):
36 | """
37 | If data is a string, it is taken as a file name.
38 |
39 | If data is bytes, it is taken as p7m data.
40 |
41 | Otherwise, data is taken as a file-like object that reads bytes data.
42 | """
43 | if isinstance(data, str):
44 | with open(data, "rb") as fd:
45 | self.data = fd.read()
46 | elif isinstance(data, bytes):
47 | self.data = data
48 | else:
49 | self.data = data.read()
50 |
51 | # Data might potentially be base64 encoded
52 |
53 | try:
54 | self.data = base64.b64decode(self.data, validate=True)
55 | except binascii.Error:
56 | pass
57 |
58 | self.content_info = ContentInfo.load(self.data)
59 |
60 | def is_expired(self) -> bool:
61 | """
62 | Check if the signature has expired
63 | """
64 | now = datetime.datetime.utcnow()
65 | signed_data = self.get_signed_data()
66 | for c in signed_data["certificates"]:
67 | if c.name != "certificate":
68 | # The signatures I've seen so far use 'certificate' only
69 | continue
70 | expiration_date = c.chosen["tbs_certificate"]["validity"]["not_after"].chosen.native.replace(tzinfo=None)
71 | if expiration_date <= now:
72 | return True
73 | return False
74 |
75 | def get_signed_data(self):
76 | """
77 | Return the SignedData part of the P7M file
78 | """
79 | if self.content_info["content_type"].native != "signed_data":
80 | raise RuntimeError("p7m data is not an instance of signed_data")
81 |
82 | signed_data = self.content_info["content"]
83 | if signed_data["version"].native != "v1":
84 | raise RuntimeError(f"ContentInfo/SignedData.version is {signed_data['version'].native} instead of v1")
85 |
86 | return signed_data
87 |
88 | def get_payload(self):
89 | """
90 | Return the raw signed data
91 | """
92 | signed_data = self.get_signed_data()
93 | encap_content_info = signed_data["encap_content_info"]
94 | return encap_content_info["content"].native
95 |
96 | def get_fattura(self):
97 | """
98 | Return the parsed XML data
99 | """
100 | data = io.BytesIO(self.get_payload())
101 | tree = ET.parse(data)
102 | return a38.auto_from_etree(tree.getroot())
103 |
104 | def verify_signature(self, certdir):
105 | """
106 | Verify the signature on the file
107 | """
108 | res = subprocess.run([
109 | "openssl", "cms", "-verify", "-inform", "DER", "-CApath", certdir, "-noout"],
110 | input=self.data,
111 | stdout=subprocess.DEVNULL,
112 | stderr=subprocess.PIPE)
113 |
114 | # From openssl cms manpage:
115 | # 0 The operation was completely successfully.
116 | # 1 An error occurred parsing the command options.
117 | # 2 One of the input files could not be read.
118 | # 3 An error occurred creating the CMS file or when reading the MIME message.
119 | # 4 An error occurred decrypting or verifying the message.
120 | # 5 The message was verified correctly but an error occurred writing out the signers certificates.
121 |
122 | if res.returncode == 0:
123 | pass
124 | elif res.returncode == 4:
125 | raise InvalidSignatureError(res.stderr)
126 | elif res.returncode == 5:
127 | raise SignerCertificateError(res.stderr)
128 | else:
129 | raise RuntimeError(res.stderr)
130 |
--------------------------------------------------------------------------------
/a38/diff.py:
--------------------------------------------------------------------------------
1 | from typing import Any, List, Optional
2 |
3 | from . import fields
4 | from .traversal import Annotation, Traversal
5 |
6 |
7 | class Difference(Annotation):
8 | def __init__(self, prefix: Optional[str], field: "fields.Field", first: Any, second: Any):
9 | super().__init__(prefix, field)
10 | self.first = first
11 | self.second = second
12 |
13 | def __str__(self):
14 | return "{}: first: {}, second: {}".format(
15 | self.qualified_field,
16 | self.field.to_str(self.first),
17 | self.field.to_str(self.second))
18 |
19 |
20 | class MissingOne(Difference):
21 | def __str__(self):
22 | if self.first is None:
23 | return "{}: first is not set".format(self.qualified_field)
24 | else:
25 | return "{}: second is not set".format(self.qualified_field)
26 |
27 |
28 | class ExtraItems(Difference):
29 | def __str__(self):
30 | if len(self.first) > len(self.second):
31 | diff = len(self.first) - len(self.second)
32 | longer = "first"
33 | else:
34 | diff = len(self.second) - len(self.first)
35 | longer = "second"
36 |
37 | if diff == 1:
38 | return "{}: {} has 1 extra element".format(self.qualified_field, longer)
39 | else:
40 | return "{}: {} has {} extra elements".format(self.qualified_field, longer, diff)
41 |
42 |
43 | class Diff(Traversal):
44 | def __init__(self, prefix: Optional[str] = None, differences: Optional[List[Difference]] = None):
45 | super().__init__(prefix)
46 | self.differences: List[Difference]
47 | if differences is None:
48 | self.differences = []
49 | else:
50 | self.differences = differences
51 |
52 | def with_prefix(self, prefix: str):
53 | return Diff(prefix, self.differences)
54 |
55 | def add_different(self, field: "fields.Field", first: Any, second: Any):
56 | self.differences.append(Difference(self.prefix, field, first, second))
57 |
58 | def add_only_first(self, field: "fields.Field", first: Any):
59 | self.differences.append(MissingOne(self.prefix, field, first, None))
60 |
61 | def add_only_second(self, field: "fields.Field", second: Any):
62 | self.differences.append(MissingOne(self.prefix, field, None, second))
63 |
64 | def add_different_length(self, field: "fields.Field", first: Any, second: Any):
65 | self.differences.append(ExtraItems(self.prefix, field, first, second))
66 |
--------------------------------------------------------------------------------
/a38/fattura_semplificata.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from . import consts, fields, models
4 | from .fattura import (Allegati, FullNameMixin, IdFiscaleIVA, IdTrasmittente,
5 | IscrizioneREA, Sede, StabileOrganizzazione)
6 |
7 | NS10 = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.0"
8 |
9 |
10 | class DatiTrasmissione(models.Model):
11 | id_trasmittente = IdTrasmittente
12 | progressivo_invio = fields.ProgressivoInvioField()
13 | formato_trasmissione = fields.StringField(length=5, choices=("FSM10",))
14 | codice_destinatario = fields.StringField(null=True, min_length=6, max_length=7, default="0000000")
15 | pec_destinatario = fields.StringField(null=True, min_length=8, max_length=256, xmltag="PECDestinatario")
16 |
17 |
18 | class RappresentanteFiscale(FullNameMixin, models.Model):
19 | id_fiscale_iva = IdFiscaleIVA
20 | denominazione = fields.StringField(max_length=80, null=True)
21 | nome = fields.StringField(max_length=60, null=True)
22 | cognome = fields.StringField(max_length=60, null=True)
23 |
24 |
25 | class CedentePrestatore(FullNameMixin, models.Model):
26 | id_fiscale_iva = IdFiscaleIVA
27 | codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
28 | denominazione = fields.StringField(max_length=80, null=True)
29 | nome = fields.StringField(max_length=60, null=True)
30 | cognome = fields.StringField(max_length=60, null=True)
31 | sede = Sede
32 | stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True)
33 | rappresentante_fiscale = models.ModelField(RappresentanteFiscale, null=True)
34 | iscrizione_rea = fields.ModelField(IscrizioneREA, null=True)
35 | regime_fiscale = fields.StringField(
36 | length=4, choices=("RF01", "RF02", "RF04", "RF05", "RF06", "RF07",
37 | "RF08", "RF09", "RF10", "RF11", "RF12", "RF13",
38 | "RF14", "RF15", "RF16", "RF17", "RF18", "RF19"))
39 |
40 |
41 | class IdentificativiFiscali(models.Model):
42 | id_fiscale_iva = IdFiscaleIVA
43 | codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
44 |
45 |
46 | class AltriDatiIdentificativi(FullNameMixin, models.Model):
47 | denominazione = fields.StringField(max_length=80, null=True)
48 | nome = fields.StringField(max_length=60, null=True)
49 | cognome = fields.StringField(max_length=60, null=True)
50 | sede = Sede
51 | stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True)
52 | rappresentante_fiscale = models.ModelField(RappresentanteFiscale, null=True)
53 |
54 |
55 | class CessionarioCommittente(models.Model):
56 | identificativi_fiscali = IdentificativiFiscali
57 | altri_dati_identificativi = AltriDatiIdentificativi
58 |
59 |
60 | class FatturaElettronicaHeader(models.Model):
61 | dati_trasmissione = DatiTrasmissione
62 | cedente_prestatore = CedentePrestatore
63 | cessionario_committente = CessionarioCommittente
64 | soggetto_emittente = fields.StringField(length=2, choices=("CC", "TZ"), null=True)
65 |
66 |
67 | class DatiGeneraliDocumento(models.Model):
68 | tipo_documento = fields.StringField(length=4, choices=("TD07", "TD08", "TD09"))
69 | divisa = fields.StringField()
70 | data = fields.DateField()
71 | numero = fields.StringField(max_length=20)
72 |
73 |
74 | class DatiFatturaRettificata(models.Model):
75 | numero_fr = fields.StringField(max_length=20, xmltag="NumeroFR")
76 | data_fr = fields.DateField(xmltag="DataFR")
77 | elementi_rettificati = fields.StringField(max_length=1000)
78 |
79 |
80 | class DatiGenerali(models.Model):
81 | dati_generali_documento = DatiGeneraliDocumento
82 | dati_fattura_rettificata = fields.ModelField(DatiFatturaRettificata, null=True)
83 |
84 |
85 | class DatiIVA(models.Model):
86 | imposta = fields.DecimalField(max_length=15)
87 | aliquota = fields.DecimalField(max_length=6)
88 |
89 |
90 | class DatiBeniServizi(models.Model):
91 | descrizione = fields.StringField(max_length=1000)
92 | importo = fields.DecimalField(max_length=15)
93 | dati_iva = DatiIVA
94 | natura = fields.StringField(length=2, null=True, choices=consts.NATURA_IVA)
95 | riferimento_normativo = fields.StringField(max_length=100, null=True)
96 |
97 |
98 | class FatturaElettronicaBody(models.Model):
99 | dati_generali = DatiGenerali
100 | dati_beni_servizi = fields.ModelListField(DatiBeniServizi)
101 | allegati = fields.ModelListField(Allegati, null=True)
102 |
103 |
104 | class FatturaElettronicaSemplificata(models.Model):
105 | """
106 | Fattura elettronica semplificata
107 | """
108 | __xmlns__ = NS10
109 | fattura_elettronica_header = FatturaElettronicaHeader
110 | fattura_elettronica_body = fields.ModelListField(FatturaElettronicaBody, min_num=1)
111 |
112 | def __init__(self, *args, **kw):
113 | super().__init__(*args, **kw)
114 | self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = self.get_versione()
115 |
116 | def get_versione(self):
117 | return "FSM10"
118 |
119 | def get_xmlattrs(self):
120 | return {"versione": self.get_versione()}
121 |
122 | def validate_model(self, validation):
123 | super().validate_model(validation)
124 | if self.get_versione() != self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione:
125 | validation.add_error(
126 | self.fattura_elettronica_header.dati_trasmissione._meta["formato_trasmissione"],
127 | "formato_trasmissione should be {}".format(self.get_versione()),
128 | code="00428")
129 |
130 | def to_xml(self, builder):
131 | with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b:
132 | with b.override_default_namespace(None) as b1:
133 | for name, field in self._meta.items():
134 | field.to_xml(b1, getattr(self, name))
135 |
136 | def build_etree(self, lxml=False):
137 | """
138 | Build and return an ElementTree with the fattura in XML format
139 | """
140 | self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = self.get_versione()
141 | if lxml:
142 | from a38.builder import LXMLBuilder
143 | builder = LXMLBuilder()
144 | else:
145 | from a38.builder import Builder
146 | builder = Builder()
147 | builder.default_namespace = NS10
148 | self.to_xml(builder)
149 | return builder.get_tree()
150 |
151 | def from_etree(self, el):
152 | versione = el.attrib.get("versione", None)
153 | if versione is None:
154 | raise RuntimeError("root element {} misses attribute 'versione'".format(el.tag))
155 |
156 | if versione != self.get_versione():
157 | raise RuntimeError("root element versione is {} instead of {}".format(versione, self.get_versione()))
158 |
159 | return super().from_etree(el)
160 |
--------------------------------------------------------------------------------
/a38/fields.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import base64
4 | import datetime
5 | import decimal
6 | import logging
7 | import re
8 | import time
9 | from decimal import Decimal
10 | from typing import (Any, Generic, List, Optional, Sequence, Tuple, TypeVar,
11 | Union)
12 |
13 | import pytz
14 | from dateutil.parser import isoparse
15 |
16 | from . import builder, validation
17 | from .diff import Diff
18 |
19 | log = logging.getLogger("a38.fields")
20 |
21 |
22 | def to_xmltag(name: str, xmlns: Optional[str] = None):
23 | tag = "".join(x.title() for x in name.split("_"))
24 | if xmlns is None:
25 | return tag
26 | return "{" + xmlns + "}" + tag
27 |
28 |
29 | T = TypeVar("T")
30 |
31 |
32 | class Field(Generic[T]):
33 | """
34 | Description of a value that can be validated and serialized to XML.
35 |
36 | It does not contain the value itself.
37 | """
38 | # True for fields that can hold a sequence of values
39 | multivalue = False
40 |
41 | def __init__(self,
42 | xmlns: Optional[str] = None,
43 | xmltag: Optional[str] = None,
44 | null: bool = False,
45 | default: Optional[T] = None):
46 | self.name: Optional[str] = None
47 | self.xmlns = xmlns
48 | self.xmltag = xmltag
49 | self.null = null
50 | self.default = default
51 |
52 | def set_name(self, name: str):
53 | """
54 | Set the field name.
55 |
56 | Used by the Model metaclass to set the field name from the metaclass
57 | attribute that defines it
58 | """
59 | self.name = name
60 |
61 | def get_construct_default(self) -> Optional[T]:
62 | """
63 | Get the default value for when a field is constructed in the Model
64 | constructor, and no value for it has been passed
65 | """
66 | return None
67 |
68 | def has_value(self, value: Optional[T]) -> bool:
69 | """
70 | Return True if this value represents a field that has been set
71 | """
72 | return value is not None
73 |
74 | def validate(self, validation: "validation.Validation", value: Any) -> Optional[T]:
75 | """
76 | Raise ValidationError(s) if the given value is not valid for this field.
77 |
78 | Return the cleaned value.
79 | """
80 | try:
81 | value = self.clean_value(value)
82 | except (TypeError, ValueError) as e:
83 | validation.add_error(self, str(e))
84 |
85 | if not self.null and not self.has_value(value):
86 | validation.add_error(self, "missing value")
87 |
88 | return value
89 |
90 | def clean_value(self, value: Any) -> Optional[T]:
91 | """
92 | Return a cleaned version of the given value
93 | """
94 | if value is None:
95 | return self.default
96 | return value
97 |
98 | def get_xmltag(self) -> str:
99 | """
100 | Return the XML tag to use for this field
101 | """
102 | if self.xmltag is not None:
103 | if self.xmlns is not None:
104 | return "{" + self.xmlns + "}" + self.xmltag
105 | else:
106 | return self.xmltag
107 | if self.name is None:
108 | raise RuntimeError("field with uninitialized name")
109 | else:
110 | return to_xmltag(self.name, self.xmlns)
111 |
112 | def to_xml(self, builder: "builder.Builder", value: Optional[T]):
113 | """
114 | Add this field to an XML tree
115 | """
116 | value = self.clean_value(value)
117 | if not self.has_value(value):
118 | return
119 | builder.add(self.get_xmltag(), self.to_str(value))
120 |
121 | def to_jsonable(self, value: Optional[T]) -> Any:
122 | """
123 | Return a json-able value for this field
124 | """
125 | return self.clean_value(value)
126 |
127 | def to_str(self, value: Optional[T]) -> str:
128 | """
129 | Return this value as a string that can be parsed by clean_value
130 | """
131 | return str(value)
132 |
133 | def to_repr(self, value: Optional[T]) -> str:
134 | """
135 | Return this value formatted for debugging
136 | """
137 | return repr(value)
138 |
139 | def to_python(self, value: Optional[T], **kw) -> str:
140 | """
141 | Return this value as a python expression
142 | """
143 | return repr(self.clean_value(value))
144 |
145 | def from_etree(self, el):
146 | """
147 | Return a value from an ElementTree Element
148 | """
149 | return self.clean_value(el.text)
150 |
151 | def diff(self, res: Diff, first: Optional[T], second: Optional[T]):
152 | """
153 | Report to res if there are differences between values first and second
154 | """
155 | first = self.clean_value(first)
156 | second = self.clean_value(second)
157 | has_first = self.has_value(first)
158 | has_second = self.has_value(second)
159 | if not has_first and not has_second:
160 | return
161 | elif has_first and not has_second:
162 | res.add_only_first(self, first)
163 | elif not has_first and has_second:
164 | res.add_only_second(self, second)
165 | elif first != second:
166 | res.add_different(self, first, second)
167 |
168 |
169 | class NotImplementedField(Field[None]):
170 | """
171 | Field acting as a placeholder for a part of the specification that is not
172 | yet implemented.
173 | """
174 | def __init__(self, warn: bool = False, **kw):
175 | super().__init__(**kw)
176 | self.warn = warn
177 |
178 | def clean_value(self, value: Any) -> None:
179 | if self.warn:
180 | log.warning("%s: value received: %r", self.name, value)
181 | return None
182 |
183 |
184 | class ChoicesField(Field[T]):
185 | def __init__(self, choices: Sequence[T] = None, **kw):
186 | super().__init__(**kw)
187 | self.choices: Optional[List[Optional[T]]]
188 | if choices is not None:
189 | self.choices = [self.clean_value(c) for c in choices]
190 | else:
191 | self.choices = None
192 |
193 | def validate(self, validation: "validation.Validation", value: Optional[T]):
194 | value = super().validate(validation, value)
195 | if value is not None and self.choices is not None and value not in self.choices:
196 | validation.add_error(self, "{} is not a valid choice for this field".format(self.to_repr(value)))
197 | return value
198 |
199 |
200 | class ListField(Field[List[T]]):
201 | multivalue = True
202 |
203 | def __init__(self, field: Field[T], min_num=0, **kw):
204 | super().__init__(**kw)
205 | self.field = field
206 | self.min_num = min_num
207 |
208 | def set_name(self, name: str):
209 | super().set_name(name)
210 | self.field.xmltag = self.get_xmltag()
211 |
212 | def get_construct_default(self):
213 | res = []
214 | for i in range(self.min_num):
215 | res.append(None)
216 | return res
217 |
218 | def clean_value(self, value):
219 | value = super().clean_value(value)
220 | if value is None:
221 | return value
222 | res = [self.field.clean_value(val) for val in value]
223 | while len(res) > self.min_num and not self.field.has_value(res[-1]):
224 | res.pop()
225 | return res
226 |
227 | def has_value(self, value):
228 | if value is None:
229 | return False
230 | for el in value:
231 | if self.field.has_value(el):
232 | return True
233 | return False
234 |
235 | def validate(self, validation, value):
236 | value = super().validate(validation, value)
237 | if not self.has_value(value):
238 | return value
239 | if len(value) < self.min_num:
240 | validation.add_error(
241 | self,
242 | "list must have at least {} elements, but has only {}".format(
243 | self.min_num, len(value)))
244 | for idx, val in enumerate(value):
245 | with validation.subfield(self.name + "." + str(idx)) as sub:
246 | self.field.validate(sub, val)
247 | return value
248 |
249 | def to_xml(self, builder, value):
250 | value = self.clean_value(value)
251 | if not self.has_value(value):
252 | return
253 | for val in value:
254 | self.field.to_xml(builder, val)
255 |
256 | def to_jsonable(self, value):
257 | value = self.clean_value(value)
258 | if not self.has_value(value):
259 | return None
260 | return [self.field.to_jsonable(val) for val in value]
261 |
262 | def to_python(self, value, **kw) -> str:
263 | value = self.clean_value(value)
264 | if not self.has_value(value):
265 | return repr(None)
266 | return "[" + ", ".join(self.field.to_python(v, **kw) for v in value) + "]"
267 |
268 | def diff(self, res: Diff, first, second):
269 | first = self.clean_value(first)
270 | second = self.clean_value(second)
271 | has_first = self.has_value(first)
272 | has_second = self.has_value(second)
273 | if not has_first and not has_second:
274 | return
275 | elif has_first and not has_second:
276 | res.add_only_first(self, first)
277 | elif not has_first and has_second:
278 | res.add_only_second(self, second)
279 | else:
280 | for idx, (el_first, el_second) in enumerate(zip(first, second)):
281 | with res.subfield(self.name + "." + str(idx)) as subres:
282 | if el_first != el_second:
283 | self.field.diff(subres, el_first, el_second)
284 | if len(first) != len(second):
285 | res.add_different_length(self, first, second)
286 |
287 | def from_etree(self, elements):
288 | values = []
289 | for el in elements:
290 | values.append(self.field.from_etree(el))
291 | return values
292 |
293 |
294 | class IntegerField(ChoicesField[int]):
295 | def __init__(self, max_length=None, **kw):
296 | super().__init__(**kw)
297 | self.max_length = max_length
298 |
299 | def clean_value(self, value):
300 | value = super().clean_value(value)
301 | if value is None:
302 | return value
303 | return int(value)
304 |
305 | def validate(self, validation, value):
306 | value = super().validate(validation, value)
307 | if not self.has_value(value):
308 | return value
309 | if self.max_length is not None and len(str(value)) > self.max_length:
310 | validation.add_error(self, "'{}' should be no more than {} digits long".format(value, self.max_length))
311 | return value
312 |
313 |
314 | class DecimalField(ChoicesField[Decimal]):
315 | def __init__(
316 | self,
317 | max_length: Optional[int] = None,
318 | decimals: Union[int, Tuple[int, int]] = 2,
319 | **kw):
320 | # Set these attributes before calling ChoicesField's __init__, since
321 | # that will call clean_value, that needs these fields
322 | self.max_length = max_length
323 | if isinstance(decimals, int):
324 | self.decimals_min = decimals
325 | self.decimals_max = decimals
326 | else:
327 | self.decimals_min, self.decimals_max = decimals
328 |
329 | super().__init__(**kw)
330 |
331 | def clean_value(self, value):
332 | value = super().clean_value(value)
333 | if value is None:
334 | return value
335 | try:
336 | dec_value = Decimal(value)
337 | except decimal.InvalidOperation:
338 | raise TypeError("{} cannot be converted to Decimal".format(repr(value)))
339 |
340 | # Enforce fitting into the required range of decimal digits
341 | sign, digits, exponent = dec_value.as_tuple()
342 | if exponent < 0:
343 | # We have decimal digits
344 | if -exponent < self.decimals_min:
345 | dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_min, rounding=decimal.ROUND_HALF_UP)
346 | elif -exponent > self.decimals_max:
347 | dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_max, rounding=decimal.ROUND_HALF_UP)
348 | else:
349 | # No decimal digits
350 | if self.decimals_min > 0:
351 | dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_min, rounding=decimal.ROUND_HALF_UP)
352 |
353 | return dec_value
354 |
355 | def to_str(self, value):
356 | if not self.has_value(value):
357 | return "None"
358 | return str(self.clean_value(value))
359 |
360 | def to_jsonable(self, value):
361 | """
362 | Return a json-able value for this field
363 | """
364 | value = self.clean_value(value)
365 | if not self.has_value(value):
366 | return None
367 | return self.to_str(value)
368 |
369 | def validate(self, validation, value):
370 | value = super().validate(validation, value)
371 | if not self.has_value(value):
372 | return value
373 | if self.max_length is not None:
374 | xml_value = self.to_str(value)
375 | if len(xml_value) > self.max_length:
376 | validation.add_error(
377 | self,
378 | "'{}' should be no more than {} digits long".format(xml_value, self.max_length))
379 | return value
380 |
381 | def diff(self, res: Diff, first: Optional[T], second: Optional[T]):
382 | """
383 | Report to res if there are differences between values first and second
384 | """
385 | first = self.clean_value(first)
386 | second = self.clean_value(second)
387 | has_first = self.has_value(first)
388 | has_second = self.has_value(second)
389 | if not has_first and not has_second:
390 | return
391 | elif has_first and not has_second:
392 | res.add_only_first(self, first)
393 | elif not has_first and has_second:
394 | res.add_only_second(self, second)
395 | elif first != second:
396 | res.add_different(self, first, second)
397 |
398 |
399 | class StringField(ChoicesField[str]):
400 | def __init__(self, length=None, min_length=None, max_length=None, **kw):
401 | super().__init__(**kw)
402 | if length is not None:
403 | if min_length is not None or max_length is not None:
404 | raise ValueError("length cannot be used with min_length or max_length")
405 | self.min_length = self.max_length = length
406 | else:
407 | self.min_length = min_length
408 | self.max_length = max_length
409 |
410 | def clean_value(self, value):
411 | value = super().clean_value(value)
412 | if value is None:
413 | return value
414 | return str(value)
415 |
416 | def validate(self, validation, value):
417 | value = super().validate(validation, value)
418 | if not self.has_value(value):
419 | return value
420 | if self.min_length is not None and len(value) < self.min_length:
421 | validation.add_error(self, "'{}' should be at least {} characters long".format(value, self.min_length))
422 | if self.max_length is not None and len(value) > self.max_length:
423 | validation.add_error(self, "'{}' should be no more than {} characters long".format(value, self.max_length))
424 | return value
425 |
426 |
427 | class Base64BinaryField(Field[bytes]):
428 | def clean_value(self, value):
429 | value = super().clean_value(value)
430 | if value is None:
431 | return value
432 | if isinstance(value, bytes):
433 | return value
434 | if isinstance(value, str):
435 | return base64.b64decode(value)
436 | raise TypeError("'{}' is not an instance of str, or bytes".format(repr(value)))
437 |
438 | def to_jsonable(self, value: Optional[T]) -> Any:
439 | """
440 | Return a json-able value for this field
441 | """
442 | return self.to_str(self.clean_value(value))
443 |
444 | def to_str(self, value: Optional[T]) -> str:
445 | """
446 | Return this value as a string that can be parsed by clean_value
447 | """
448 | if value is None:
449 | return None
450 | return base64.b64encode(value).decode("utf8")
451 |
452 |
453 | class DateField(ChoicesField[datetime.date]):
454 | re_clean_date = re.compile(r"^\s*(\d{4}-\d{1,2}-\d{1,2})")
455 |
456 | def clean_value(self, value):
457 | value = super().clean_value(value)
458 | if value is None:
459 | return value
460 | if isinstance(value, str):
461 | mo = self.re_clean_date.match(value)
462 | if not mo:
463 | raise ValueError("Date '{}' does not begin with YYYY-mm-dd".format(value))
464 | return datetime.datetime.strptime(mo.group(1), "%Y-%m-%d").date()
465 | elif isinstance(value, datetime.datetime):
466 | return value.date()
467 | elif isinstance(value, datetime.date):
468 | return value
469 | else:
470 | raise TypeError("'{}' is not an instance of str, datetime.date or datetime.datetime".format(repr(value)))
471 |
472 | def to_jsonable(self, value):
473 | """
474 | Return a json-able value for this field
475 | """
476 | value = self.clean_value(value)
477 | if not self.has_value(value):
478 | return None
479 | return self.to_str(value)
480 |
481 | def to_str(self, value):
482 | if value is None:
483 | return "None"
484 | return value.strftime("%Y-%m-%d")
485 |
486 |
487 | class DateTimeField(ChoicesField[datetime.datetime]):
488 | tz_rome = pytz.timezone("Europe/Rome")
489 |
490 | def clean_value(self, value):
491 | value = super().clean_value(value)
492 | if value is None:
493 | return value
494 | if isinstance(value, str):
495 | res = isoparse(value)
496 | if res.tzinfo is None:
497 | res = self.tz_rome.localize(res)
498 | return res
499 | elif isinstance(value, datetime.datetime):
500 | if value.tzinfo is None:
501 | return self.tz_rome.localize(value)
502 | return value
503 | elif isinstance(value, datetime.date):
504 | return datetime.datetime.combine(value, datetime.time(0, 0, 0, tzinfo=self.tz_rome))
505 | else:
506 | raise TypeError("'{}' is not an instance of str, datetime.date or datetime.datetime".format(repr(value)))
507 |
508 | def to_jsonable(self, value):
509 | """
510 | Return a json-able value for this field
511 | """
512 | value = self.clean_value(value)
513 | if not self.has_value(value):
514 | return None
515 | return self.to_str(value)
516 |
517 | def to_python(self, value, **kw):
518 | value = self.clean_value(value)
519 | if not self.has_value(value):
520 | return repr(value)
521 | return repr(value.isoformat())
522 |
523 | def to_str(self, value):
524 | if not self.has_value(value):
525 | return "None"
526 | return value.isoformat()
527 |
528 | def to_repr(self, value):
529 | if not self.has_value(value):
530 | return "None"
531 | return value.isoformat()
532 |
533 |
534 | class ProgressivoInvioField(StringField):
535 | CHARS = "+-./0123456789=@ABCDEFGHIJKLMNOPQRSTUVWXYZ_"
536 | TS_RANGE = 2 ** (54 - 16)
537 | SEQUENCE_RANGE = 2 ** 16
538 | last_ts = None
539 | sequence = 0
540 |
541 | def __init__(self, **kw):
542 | kw["max_length"] = 10
543 | super().__init__(**kw)
544 |
545 | def _encode_b56(self, value, places):
546 | res = []
547 | while value > 0:
548 | res.append(self.CHARS[value % 43])
549 | value //= 43
550 | return "".join(res[::-1])
551 |
552 | def get_construct_default(self):
553 | ts = int(time.time())
554 | if self.last_ts is None or self.last_ts != ts:
555 | self.sequence = 0
556 | self.last_ts = ts
557 | else:
558 | self.sequence += 1
559 | if self.sequence > (64 ** 3):
560 | raise OverflowError(
561 | "Generated more than {} fatture per second, overflowing local counter".format(64 ** 3))
562 |
563 | value = (ts << 16) + self.sequence
564 | return self._encode_b56(value, 10)
565 |
566 |
567 | class ModelField(Field):
568 | """
569 | Field containing the structure from a Model
570 | """
571 | def __init__(self, model, **kw):
572 | super().__init__(**kw)
573 | self.model = model
574 |
575 | def __str__(self):
576 | return "ModelField({})".format(self.model.__name__)
577 |
578 | __repr__ = __str__
579 |
580 | def get_construct_default(self):
581 | return self.model()
582 |
583 | def clean_value(self, value):
584 | value = super().clean_value(value)
585 | if value is None:
586 | return value
587 | return self.model.clean_value(value)
588 |
589 | def has_value(self, value):
590 | if value is None:
591 | return False
592 | return value.has_value()
593 |
594 | def get_xmltag(self):
595 | if self.xmltag is not None:
596 | if self.xmlns is not None:
597 | return "{" + self.xmlns + "}" + self.xmltag
598 | else:
599 | return self.xmltag
600 | return self.model.get_xmltag()
601 |
602 | def validate(self, validation, value):
603 | value = super().validate(validation, value)
604 | if not self.has_value(value):
605 | return value
606 |
607 | with validation.subfield(self.name) as sub:
608 | value.validate_fields(sub)
609 |
610 | value.validate_model(validation)
611 | return value
612 |
613 | def to_xml(self, builder, value):
614 | value = self.clean_value(value)
615 | if not self.has_value(value):
616 | return
617 | value.to_xml(builder)
618 |
619 | def to_jsonable(self, value):
620 | value = self.clean_value(value)
621 | if not self.has_value(value):
622 | return None
623 | return value.to_jsonable()
624 |
625 | def to_python(self, value, **kw) -> str:
626 | value = self.clean_value(value)
627 | if not self.has_value(value):
628 | return repr(None)
629 | return value.to_python(**kw)
630 |
631 | def diff(self, res: Diff, first, second):
632 | first = self.clean_value(first)
633 | second = self.clean_value(second)
634 | has_first = self.has_value(first)
635 | has_second = self.has_value(second)
636 | if not has_first and not has_second:
637 | return
638 | elif has_first and not has_second:
639 | res.add_only_first(self, first)
640 | elif not has_first and has_second:
641 | res.add_only_second(self, first)
642 | else:
643 | with res.subfield(self.name) as subres:
644 | first.diff(subres, second)
645 |
646 | def from_etree(self, el):
647 | res = self.model()
648 | res.from_etree(el)
649 | return res
650 |
651 |
652 | class ModelListField(Field):
653 | """
654 | Field containing a list of model instances
655 | """
656 | multivalue = True
657 |
658 | def __init__(self, model, min_num=0, **kw):
659 | super().__init__(**kw)
660 | self.model = model
661 | self.min_num = min_num
662 |
663 | def get_construct_default(self):
664 | res = []
665 | for i in range(self.min_num):
666 | res.append(self.model())
667 | return res
668 |
669 | def clean_value(self, value):
670 | value = super().clean_value(value)
671 | if value is None:
672 | return value
673 | res = [self.model.clean_value(val) for val in value]
674 | while len(res) > self.min_num and (res[-1] is None or not res[-1].has_value()):
675 | res.pop()
676 | return res
677 |
678 | def has_value(self, value):
679 | if value is None:
680 | return False
681 |
682 | for el in value:
683 | if el.has_value():
684 | return True
685 |
686 | return False
687 |
688 | def get_xmltag(self):
689 | if self.xmltag is not None:
690 | return self.xmltag
691 | return self.model.get_xmltag()
692 |
693 | def validate(self, validation, value):
694 | value = super().validate(validation, value)
695 | if not self.has_value(value):
696 | return value
697 |
698 | if len(value) < self.min_num:
699 | validation.add_error(
700 | self,
701 | "list must have at least {} elements, but has only {}".format(self.min_num, len(value)))
702 |
703 | for idx, val in enumerate(value):
704 | with validation.subfield(self.name + "." + str(idx)) as sub:
705 | val.validate_fields(sub)
706 |
707 | val.validate_model(validation)
708 | return value
709 |
710 | def to_xml(self, builder, value):
711 | value = self.clean_value(value)
712 | if not self.has_value(value):
713 | return
714 | for val in value:
715 | val.to_xml(builder)
716 |
717 | def to_jsonable(self, value):
718 | value = self.clean_value(value)
719 | if not self.has_value(value):
720 | return None
721 | return [val.to_jsonable() for val in value]
722 |
723 | def to_python(self, value, **kw) -> str:
724 | value = self.clean_value(value)
725 | if not self.has_value(value):
726 | return repr(None)
727 | return "[" + ", ".join(v.to_python(**kw) for v in value) + "]"
728 |
729 | def diff(self, res: Diff, first, second):
730 | first = self.clean_value(first)
731 | second = self.clean_value(second)
732 | has_first = self.has_value(first)
733 | has_second = self.has_value(second)
734 | if not has_first and not has_second:
735 | return
736 | if has_first and not has_second:
737 | res.add_only_first(self, first)
738 | elif not has_first and has_second:
739 | res.add_only_second(self, second)
740 | else:
741 | for idx, (el_first, el_second) in enumerate(zip(first, second)):
742 | with res.subfield(self.name + "." + str(idx)) as subres:
743 | el_first.diff(subres, el_second)
744 |
745 | if len(first) != len(second):
746 | res.add_different_length(self, first, second)
747 |
748 | def from_etree(self, elements):
749 | values = []
750 | for el in elements:
751 | value = self.model()
752 | value.from_etree(el)
753 | values.append(value)
754 | return values
755 |
--------------------------------------------------------------------------------
/a38/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from collections import defaultdict
4 | from typing import Any, Dict, Optional, Tuple
5 |
6 | from .fields import Field, ModelField
7 | from .validation import Validation
8 |
9 |
10 | class ModelBase:
11 | __slots__ = ()
12 |
13 | def __init__(self):
14 | pass
15 |
16 | @classmethod
17 | def get_xmltag(cls) -> str:
18 | xmltag = getattr(cls, "__xmltag__", None)
19 | if xmltag is None:
20 | xmltag = cls.__name__
21 |
22 | xmlns = getattr(cls, "__xmlns__", None)
23 | if xmlns:
24 | return "{" + xmlns + "}" + xmltag
25 | else:
26 | return xmltag
27 |
28 | def get_xmlattrs(self) -> Dict[str, str]:
29 | return {}
30 |
31 |
32 | class ModelMetaclass(type):
33 | def __new__(cls, name, bases, dct):
34 | _meta = {}
35 |
36 | # Add fields from subclasses
37 | for b in bases:
38 | if not issubclass(b, ModelBase):
39 | continue
40 | b_meta = getattr(b, "_meta", None)
41 | if b_meta is None:
42 | continue
43 | _meta.update(b_meta)
44 |
45 | # Add fields from the class itself
46 | slots = []
47 | for field_name, val in list(dct.items()):
48 | if isinstance(val, Field):
49 | # Store its description in the Model _meta
50 | _meta[field_name] = val
51 | val.set_name(field_name)
52 | elif isinstance(val, type) and issubclass(val, ModelBase):
53 | # Store its description in the Model _meta
54 | val = ModelField(val)
55 | _meta[field_name] = val
56 | val.set_name(field_name)
57 | else:
58 | # Leave untouched
59 | continue
60 |
61 | # Remove field_name from class variables
62 | del dct[field_name]
63 | # Add it as a slot in the instance
64 | slots.append(field_name)
65 |
66 | dct["__slots__"] = slots
67 | res = super().__new__(cls, name, bases, dct)
68 | res._meta = _meta
69 | return res
70 |
71 |
72 | class Model(ModelBase, metaclass=ModelMetaclass):
73 | """
74 | Declarative description of a data structure that can be validated and
75 | serialized to XML.
76 | """
77 | __slots__ = ()
78 |
79 | def __init__(self, *args, **kw):
80 | super().__init__()
81 | for name, value in zip(self._meta.keys(), args):
82 | kw[name] = value
83 |
84 | for name, field in self._meta.items():
85 | value = kw.pop(name, None)
86 | if value is None:
87 | value = field.get_construct_default()
88 | else:
89 | value = field.clean_value(value)
90 | setattr(self, name, value)
91 |
92 | def update(self, *args, **kw):
93 | """
94 | Set multiple values in the model.
95 |
96 | Arguments are treated in the same way as in the constructor. Any field
97 | not mentioned is left untouched.
98 | """
99 | for name, value in zip(self._meta.keys(), args):
100 | setattr(self, name, value)
101 |
102 | for name, value in kw.items():
103 | setattr(self, name, value)
104 |
105 | def has_value(self):
106 | for name, field in self._meta.items():
107 | if field.has_value(getattr(self, name)):
108 | return True
109 | return False
110 |
111 | @classmethod
112 | def clean_value(cls, value: Any) -> Optional["Model"]:
113 | """
114 | Create a model from the given value.
115 |
116 | Always make a copy even if value is already of the right class, to
117 | prevent mutability issues.
118 | """
119 | if value is None:
120 | return None
121 | if isinstance(value, dict):
122 | return cls(**value)
123 | elif isinstance(value, ModelBase):
124 | kw = {}
125 | for name, field in cls._meta.items():
126 | kw[name] = getattr(value, name, None)
127 | return cls(**kw)
128 | else:
129 | raise TypeError(f"{cls.__name__}: {value!r} is {type(value).__name__}"
130 | " instead of a Model or dict instance")
131 |
132 | def validate_fields(self, validation: Validation):
133 | for name, field in self._meta.items():
134 | field.validate(validation, getattr(self, name))
135 |
136 | def validate_model(self, validation: Validation):
137 | pass
138 |
139 | def validate(self, validation: Validation):
140 | self.validate_fields(validation)
141 | self.validate_model(validation)
142 |
143 | def to_jsonable(self):
144 | res = {}
145 | for name, field in self._meta.items():
146 | value = field.to_jsonable(getattr(self, name))
147 | if value is not None:
148 | res[name] = value
149 | return res
150 |
151 | def to_python(self, **kw) -> str:
152 | args = []
153 | for name, field in self._meta.items():
154 | value = getattr(self, name)
155 | if not field.has_value(value):
156 | continue
157 | args.append(name + "=" + field.to_python(value, **kw))
158 | namespace = kw.get("namespace")
159 | if namespace is None:
160 | constructor = self.__class__.__module__ + "." + self.__class__.__qualname__
161 | elif namespace is False:
162 | constructor = self.__class__.__qualname__
163 | else:
164 | constructor = namespace + "." + self.__class__.__qualname__
165 | return "{}({})".format(constructor, ", ".join(args))
166 |
167 | def to_xml(self, builder):
168 | with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b:
169 | for name, field in self._meta.items():
170 | field.to_xml(b, getattr(self, name))
171 |
172 | def __setattr__(self, key: str, value: any):
173 | field = self._meta.get(key, None)
174 | if field is not None:
175 | value = field.clean_value(value)
176 | super().__setattr__(key, value)
177 |
178 | def _to_tuple(self) -> Tuple[Any]:
179 | return tuple(getattr(self, name) for name in self._meta.keys())
180 |
181 | def __eq__(self, other):
182 | other = self.clean_value(other)
183 | has_self = self.has_value()
184 | has_other = other is not None and other.has_value()
185 | if not has_self and not has_other:
186 | return True
187 | if has_self != has_other:
188 | return False
189 | return self._to_tuple() == other._to_tuple()
190 |
191 | def __ne__(self, other):
192 | other = self.clean_value(other)
193 | has_self = self.has_value()
194 | has_other = other is not None and other.has_value()
195 | if not has_self and not has_other:
196 | return False
197 | if has_self != has_other:
198 | return True
199 | return self._to_tuple() != other._to_tuple()
200 |
201 | def __lt__(self, other):
202 | other = self.clean_value(other)
203 | has_self = self.has_value()
204 | has_other = other is not None and other.has_value()
205 | if not has_self and not has_other:
206 | return False
207 | if has_self and not has_other:
208 | return False
209 | if not has_self and has_other:
210 | return True
211 | return self._to_tuple() < other._to_tuple()
212 |
213 | def __gt__(self, other):
214 | other = self.clean_value(other)
215 | has_self = self.has_value()
216 | has_other = other is not None and other.has_value()
217 | if not has_self and not has_other:
218 | return False
219 | if has_self and not has_other:
220 | return True
221 | if not has_self and has_other:
222 | return False
223 | return self._to_tuple() > other._to_tuple()
224 |
225 | def __le__(self, other):
226 | other = self.clean_value(other)
227 | has_self = self.has_value()
228 | has_other = other is not None and other.has_value()
229 | if not has_self and not has_other:
230 | return True
231 | if has_self and not has_other:
232 | return False
233 | if not has_self and has_other:
234 | return True
235 | return self._to_tuple() <= other._to_tuple()
236 |
237 | def __ge__(self, other):
238 | other = self.clean_value(other)
239 | has_self = self.has_value()
240 | has_other = other is not None and other.has_value()
241 | if not has_self and not has_other:
242 | return True
243 | if has_self and not has_other:
244 | return True
245 | if not has_self and has_other:
246 | return False
247 | return self._to_tuple() >= other._to_tuple()
248 |
249 | def __str__(self):
250 | vals = []
251 | for name, field in self._meta.items():
252 | vals.append(name + "=" + field.to_str(getattr(self, name)))
253 | return "{}({})".format(self.__class__.__name__, ", ".join(vals))
254 |
255 | def __repr__(self):
256 | vals = []
257 | for name, field in self._meta.items():
258 | vals.append(name + "=" + field.to_str(getattr(self, name)))
259 | return "{}({})".format(self.__class__.__name__, ", ".join(vals))
260 |
261 | def from_etree(self, el):
262 | if el.tag != self.get_xmltag():
263 | raise RuntimeError("element is {} instead of {}".format(el.tag, self.get_xmltag()))
264 |
265 | tag_map = {field.get_xmltag(): (name, field) for name, field in self._meta.items()}
266 |
267 | # Group values by tag
268 | by_name = defaultdict(list)
269 | for child in el:
270 | try:
271 | name, field = tag_map[child.tag]
272 | except KeyError:
273 | raise RuntimeError("found unexpected element {} in {}".format(child.tag, el.tag))
274 |
275 | by_name[name].append(child)
276 |
277 | for name, elements in by_name.items():
278 | field = self._meta[name]
279 | if field.multivalue:
280 | setattr(self, name, field.from_etree(elements))
281 | elif len(elements) != 1:
282 | raise RuntimeError(
283 | "found {} {} elements in {} instead of just 1".format(
284 | len(elements), child.tag, el.tag))
285 | else:
286 | setattr(self, name, field.from_etree(elements[0]))
287 |
288 | def diff(self, diff, other):
289 | has_self = self.has_value()
290 | has_other = other.has_value()
291 | if not has_self and not has_other:
292 | return
293 | if has_self != has_other:
294 | diff.add(None, self, other)
295 | return
296 | for name, field in self._meta.items():
297 | first = getattr(self, name)
298 | second = getattr(other, name)
299 | field.diff(diff, first, second)
300 |
--------------------------------------------------------------------------------
/a38/render.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import tempfile
4 | from typing import Optional
5 |
6 | try:
7 | import lxml.etree
8 | HAVE_LXML = True
9 | except ModuleNotFoundError:
10 | HAVE_LXML = False
11 |
12 |
13 | if HAVE_LXML:
14 | class XSLTTransform:
15 | def __init__(self, xslt):
16 | parsed_xslt = lxml.etree.parse(xslt)
17 | self.xslt = lxml.etree.XSLT(parsed_xslt)
18 |
19 | def __call__(self, f):
20 | """
21 | Return the ElementTree for f rendered as HTML
22 | """
23 | tree = f.build_etree(lxml=True)
24 | return self.xslt(tree)
25 |
26 | def _requires_enable_local_file_access(self, wkhtmltopdf: str):
27 | """
28 | Check if we need to pass --enable-local-file-access to wkhtmltopdf.
29 |
30 | See https://github.com/Truelite/python-a38/issues/6 for details
31 | """
32 | # We need to specifically use --extended-help, because --help does
33 | # not always document --enable-local-file-access
34 | verifyLocalAccessToFileOption = subprocess.run(
35 | [wkhtmltopdf, "--extended-help"], stdin=subprocess.DEVNULL, text=True, capture_output=True)
36 | return "--enable-local-file-access" in verifyLocalAccessToFileOption.stdout
37 |
38 | def to_pdf(self, wkhtmltopdf: str, f, output_file: Optional[str] = None):
39 | """
40 | Render a fattura to PDF using the given wkhtmltopdf command.
41 |
42 | Returns None if output_file is given, or the binary PDF data if not
43 | """
44 | if output_file is None:
45 | output_file = "-"
46 | html = self(f)
47 |
48 | # TODO: pass html data as stdin, using '-' as input for
49 | # wkhtmltopdf: that currently removes the requirement for
50 | # --enable-local-file-access
51 | with tempfile.NamedTemporaryFile("wb", suffix=".html", delete=False) as fd:
52 | html.write(fd)
53 | tempFilename = fd.name
54 |
55 | try:
56 | cmdLine = [wkhtmltopdf, tempFilename, output_file]
57 | if self._requires_enable_local_file_access(wkhtmltopdf):
58 | cmdLine.insert(1, "--enable-local-file-access")
59 |
60 | res = subprocess.run(cmdLine, stdin=subprocess.DEVNULL, capture_output=True)
61 |
62 | if res.returncode != 0:
63 | raise RuntimeError(
64 | "{0} exited with error {1}: stderr: {2!r}".format(
65 | wkhtmltopdf, res.returncode, res.stderr))
66 |
67 | if output_file == "-":
68 | return res.stdout
69 | else:
70 | return None
71 | finally:
72 | os.remove(tempFilename)
73 |
--------------------------------------------------------------------------------
/a38/traversal.py:
--------------------------------------------------------------------------------
1 | from contextlib import contextmanager
2 | from typing import TYPE_CHECKING, Optional
3 |
4 | if TYPE_CHECKING:
5 | from . import fields
6 |
7 |
8 | class Annotation:
9 | def __init__(self, prefix: Optional[str], field: "fields.Field"):
10 | self.prefix = prefix
11 | self.field = field
12 |
13 | @property
14 | def qualified_field(self) -> str:
15 | if self.prefix is None:
16 | return self.field.name
17 | elif self.field.name is None:
18 | return self.prefix
19 | else:
20 | return self.prefix + "." + self.field.name
21 |
22 |
23 | class Traversal:
24 | def __init__(self, prefix: Optional[str] = None):
25 | self.prefix = prefix
26 |
27 | def with_prefix(self, prefix: str) -> "Traversal":
28 | raise NotImplementedError("Traversal subclasses must implement with_prefix")
29 |
30 | @contextmanager
31 | def subfield(self, name: str):
32 | if self.prefix is None:
33 | prefix = name
34 | else:
35 | prefix = self.prefix + "." + name
36 | yield self.with_prefix(prefix)
37 |
--------------------------------------------------------------------------------
/a38/trustedlist.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import logging
3 | import re
4 | import subprocess
5 |
6 | try:
7 | from defusedxml import ElementTree as ET
8 | except ModuleNotFoundError:
9 | import xml.etree.ElementTree as ET
10 |
11 | from collections import defaultdict
12 | from pathlib import Path
13 | from typing import Dict
14 |
15 | from cryptography import x509
16 |
17 | from . import fields, models
18 |
19 | log = logging.getLogger("__name__")
20 |
21 | NS = "http://uri.etsi.org/02231/v2#"
22 | NS_XMLDSIG = "http://www.w3.org/2000/09/xmldsig#"
23 | NS_ADDTYPES = "http://uri.etsi.org/02231/v2/additionaltypes#"
24 |
25 |
26 | class OtherInformation(models.Model):
27 | __xmlns__ = NS
28 | tsl_type = fields.NotImplementedField(xmltag="TSLType", xmlns=NS)
29 | scheme_territory = fields.StringField(null=True, xmlns=NS)
30 | mime_type = fields.StringField(null=True, xmlns=NS_ADDTYPES)
31 | scheme_operator_name = fields.NotImplementedField(xmlns=NS)
32 | scheme_type_community_rules = fields.NotImplementedField(xmlns=NS)
33 |
34 |
35 | class AdditionalInformation(models.Model):
36 | __xmlns__ = NS
37 | other_information = fields.ModelListField(OtherInformation)
38 |
39 |
40 | class OtherTSLPointer(models.Model):
41 | __xmlns__ = NS
42 | tsl_location = fields.StringField(xmltag="TSLLocation", xmlns=NS)
43 | service_digital_identities = fields.NotImplementedField(xmlns=NS)
44 | additional_information = AdditionalInformation
45 |
46 |
47 | class PointersToOtherTSL(models.Model):
48 | __xmlns__ = NS
49 | other_tsl_pointer = fields.ModelListField(OtherTSLPointer)
50 |
51 |
52 | class SchemeInformation(models.Model):
53 | __xmlns__ = NS
54 | pointers_to_other_tsl = fields.ModelField(PointersToOtherTSL)
55 | tsl_version_identifier = fields.NotImplementedField(xmltag="TSLVersionIdentifier", xmlns=NS)
56 | tsl_sequence_number = fields.NotImplementedField(xmltag="TSLSequenceNumber", xmlns=NS)
57 | tsl_type = fields.NotImplementedField(xmltag="TSLType", xmlns=NS)
58 | scheme_operator_name = fields.NotImplementedField(xmlns=NS)
59 | scheme_operator_address = fields.NotImplementedField(xmlns=NS)
60 | scheme_information_uri = fields.NotImplementedField(xmltag="SchemeInformationURI", xmlns=NS)
61 | scheme_name = fields.NotImplementedField(xmlns=NS)
62 | status_determination_approach = fields.NotImplementedField(xmlns=NS)
63 | scheme_type_community_rules = fields.NotImplementedField(xmlns=NS)
64 | scheme_territory = fields.NotImplementedField(xmlns=NS)
65 | policy_or_legal_notice = fields.NotImplementedField(xmlns=NS)
66 | historical_information_period = fields.NotImplementedField(xmlns=NS)
67 | list_issue_date_time = fields.NotImplementedField(xmlns=NS)
68 | next_update = fields.NotImplementedField(xmlns=NS)
69 | distribution_points = fields.NotImplementedField(xmlns=NS)
70 |
71 |
72 | class TSPInformation(models.Model):
73 | __xmlns__ = NS
74 | tsp_name = fields.NotImplementedField(xmltag="TSPName", xmlns=NS)
75 | tsp_trade_name = fields.NotImplementedField(xmltag="TSPTradeName", xmlns=NS)
76 | tsp_address = fields.NotImplementedField(xmltag="TSPAddress", xmlns=NS)
77 | tsp_information_url = fields.NotImplementedField(xmltag="TSPInformationURI", xmlns=NS)
78 |
79 |
80 | class DigitalId(models.Model):
81 | __xmlns__ = NS
82 | x509_subject_name = fields.StringField(xmltag="X509SubjectName", xmlns=NS, null=True)
83 | x509_ski = fields.StringField(xmltag="X509SKI", xmlns=NS, null=True)
84 | x509_certificate = fields.StringField(xmltag="X509Certificate", xmlns=NS, null=True)
85 |
86 |
87 | class ServiceDigitalIdentity(models.Model):
88 | __xmlns__ = NS
89 | digital_id = fields.ModelListField(DigitalId)
90 |
91 |
92 | class ServiceInformation(models.Model):
93 | __xmlns__ = NS
94 | service_type_identifier = fields.StringField(xmlns=NS)
95 | service_name = fields.NotImplementedField(xmlns=NS)
96 | service_digital_identity = ServiceDigitalIdentity
97 | service_status = fields.StringField(xmlns=NS)
98 | status_starting_time = fields.NotImplementedField(xmlns=NS)
99 | service_information_extensions = fields.NotImplementedField(xmlns=NS)
100 |
101 |
102 | class TSPService(models.Model):
103 | __xmlns__ = NS
104 | service_information = ServiceInformation
105 | service_history = fields.NotImplementedField(xmlns=NS)
106 |
107 |
108 | class TSPServices(models.Model):
109 | __xmlns__ = NS
110 | tsp_service = fields.ModelListField(TSPService)
111 |
112 |
113 | class TrustServiceProvider(models.Model):
114 | __xmlns__ = NS
115 | tsp_information = TSPInformation
116 | tsp_services = TSPServices
117 |
118 |
119 | class TrustServiceProviderList(models.Model):
120 | __xmlns__ = NS
121 | trust_service_provider = fields.ModelListField(TrustServiceProvider)
122 |
123 |
124 | class TrustServiceStatusList(models.Model):
125 | __xmlns__ = NS
126 | scheme_information = SchemeInformation
127 | signature = fields.NotImplementedField(xmlns=NS_XMLDSIG)
128 | trust_service_provider_list = TrustServiceProviderList
129 |
130 | def get_tsl_pointer_by_territory(self, territory):
131 | for other_tsl_pointer in self.scheme_information.pointers_to_other_tsl.other_tsl_pointer:
132 | territory = None
133 | for oi in other_tsl_pointer.additional_information.other_information:
134 | if oi.scheme_territory is not None:
135 | territory = oi.scheme_territory
136 | break
137 | if territory != "IT":
138 | continue
139 | return other_tsl_pointer.tsl_location
140 |
141 |
142 | def auto_from_etree(root):
143 | expected_tag = "{{{}}}TrustServiceStatusList".format(NS)
144 | if root.tag != expected_tag:
145 | raise RuntimeError("Root element {} is not {}".format(root.tag, expected_tag))
146 |
147 | res = TrustServiceStatusList()
148 | res.from_etree(root)
149 | return res
150 |
151 |
152 | def load_url(url: str):
153 | """
154 | Return a TrustedServiceStatusList instance from the XML downloaded from the
155 | given URL
156 | """
157 | import requests
158 | res = requests.get(url)
159 | res.raise_for_status()
160 | root = ET.fromstring(res.content)
161 | return auto_from_etree(root)
162 |
163 |
164 | def load_certs() -> Dict[str, x509.Certificate]:
165 | """
166 | Download trusted list certificates for Italy, parse them and return a dict
167 | mapping certificate names good for use as file names to cryptography.x509
168 | certificates
169 | """
170 | re_clean_fname = re.compile(r"[^A-Za-z0-9_-]")
171 |
172 | eu_url = "https://ec.europa.eu/information_society/policy/esignature/trusted-list/tl-mp.xml"
173 | log.info("Downloading EU index from %s", eu_url)
174 | eu_tl = load_url(eu_url)
175 | it_url = eu_tl.get_tsl_pointer_by_territory("IT")
176 | log.info("Downloading IT data from %s", it_url)
177 | trust_service_status_list = load_url(it_url)
178 |
179 | by_name = defaultdict(list)
180 | for tsp in trust_service_status_list.trust_service_provider_list.trust_service_provider:
181 | for tsp_service in tsp.tsp_services.tsp_service:
182 | si = tsp_service.service_information
183 | if si.service_status not in (
184 | "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/recognisedatnationallevel",
185 | "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/granted"):
186 | continue
187 | if si.service_type_identifier not in (
188 | "http://uri.etsi.org/TrstSvc/Svctype/CA/QC",):
189 | continue
190 | # print("identifier", si.service_type_identifier)
191 | # print("status", si.service_status)
192 | cert = []
193 | sn = []
194 | for di in si.service_digital_identity.digital_id:
195 | if di.x509_subject_name is not None:
196 | sn.append(di.x509_subject_name)
197 | # if di.x509_ski is not None:
198 | # print(" SKI:", di.x509_ski)
199 | if di.x509_certificate is not None:
200 | from cryptography import x509
201 | from cryptography.hazmat.backends import default_backend
202 | der = base64.b64decode(di.x509_certificate)
203 | cert.append(x509.load_der_x509_certificate(der, default_backend()))
204 |
205 | if len(cert) == 0:
206 | raise RuntimeError("{} has no certificates".format(sn))
207 | elif len(cert) > 1:
208 | raise RuntimeError("{} has {} certificates".format(sn, len(cert)))
209 | else:
210 | from cryptography.x509.oid import NameOID
211 | cert = cert[0]
212 | cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
213 | # print("sn", sn)
214 | # print(cert)
215 | # print("full cn", cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME))
216 | # print("cn", cn)
217 | fname = re_clean_fname.sub("_", cn)
218 | by_name[fname].append(cert)
219 |
220 | res = {}
221 | for name, certs in by_name.items():
222 | if len(certs) == 1:
223 | if name in res:
224 | raise RuntimeError("{} already in result".format(name))
225 | res[name] = certs[0]
226 | else:
227 | for idx, cert in enumerate(certs, start=1):
228 | idxname = name + "_a38_{}".format(idx)
229 | if idxname in res:
230 | raise RuntimeError("{} already in result".format(name))
231 | res[idxname] = cert
232 | return res
233 |
234 |
235 | def update_capath(destdir: Path, remove_old=False):
236 | from cryptography.hazmat.primitives import serialization
237 | certs = load_certs()
238 | if destdir.is_dir():
239 | current = set(c.name for c in destdir.iterdir() if c.name.endswith(".crt"))
240 | else:
241 | current = set()
242 | destdir.mkdir(parents=True)
243 | for name, cert in certs.items():
244 | fname = name + ".crt"
245 | current.discard(fname)
246 | pathname = destdir / fname
247 | with pathname.open(mode="wb") as fd:
248 | fd.write(cert.public_bytes(serialization.Encoding.PEM))
249 | log.info("%s: written", pathname)
250 | if remove_old:
251 | for fname in current:
252 | pathname = destdir / fname
253 | pathname.unlink()
254 | log.info("%s: removed", pathname)
255 |
256 | subprocess.run(["openssl", "rehash", destdir], check=True)
257 |
--------------------------------------------------------------------------------
/a38/validation.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional, Sequence, Union
2 |
3 | from . import fields
4 | from .traversal import Annotation, Traversal
5 |
6 |
7 | class ValidationError(Annotation):
8 | def __init__(self, prefix: Optional[str], field: "fields.Field", msg: str, code: str = None):
9 | self.prefix = prefix
10 | self.field = field
11 | self.msg = msg
12 | self.code = code
13 |
14 | def __str__(self):
15 | if self.code is not None:
16 | return "{}: [{}] {}".format(self.qualified_field, self.code, self.msg)
17 | else:
18 | return "{}: {}".format(self.qualified_field, self.msg)
19 |
20 |
21 | Fields = Union["fields.Field", Sequence["fields.Field"]]
22 |
23 |
24 | class Validation(Traversal):
25 | def __init__(self,
26 | prefix: Optional[str] = None,
27 | warnings: Optional[List[ValidationError]] = None,
28 | errors: Optional[List[ValidationError]] = None):
29 | super().__init__(prefix)
30 | self.warnings: List[ValidationError]
31 | self.errors: List[ValidationError]
32 | if warnings is None:
33 | self.warnings = []
34 | else:
35 | self.warnings = warnings
36 | if errors is None:
37 | self.errors = []
38 | else:
39 | self.errors = errors
40 |
41 | def with_prefix(self, prefix: str):
42 | return Validation(prefix, self.warnings, self.errors)
43 |
44 | def add_warning(self, field: Fields, msg: str, code: str = None):
45 | if isinstance(field, fields.Field):
46 | self.warnings.append(ValidationError(self.prefix, field, msg, code))
47 | else:
48 | for f in field:
49 | self.warnings.append(ValidationError(self.prefix, f, msg, code))
50 |
51 | def add_error(self, field: Fields, msg: str, code: str = None):
52 | if isinstance(field, fields.Field):
53 | self.errors.append(ValidationError(self.prefix, field, msg, code))
54 | else:
55 | for f in field:
56 | self.errors.append(ValidationError(self.prefix, f, msg, code))
57 |
--------------------------------------------------------------------------------
/a38tool:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | from __future__ import annotations
3 | import argparse
4 | import contextlib
5 | import fnmatch
6 | import logging
7 | import os.path
8 | import re
9 | import shutil
10 | import sys
11 | from pathlib import Path
12 | from typing import TYPE_CHECKING, Optional, IO, Union
13 |
14 | from a38 import codec, models
15 |
16 | if TYPE_CHECKING:
17 | import fattura
18 | from .fattura import Fattura
19 | from .fattura_semplificata import FatturaElettronicaSemplificata
20 |
21 | log = logging.getLogger("a38tool")
22 |
23 |
24 | class Fail(Exception):
25 | pass
26 |
27 |
28 | class App:
29 | NAME = None
30 |
31 | def __init__(self, args):
32 | self.args = args
33 |
34 | def load_fattura(self, pathname) -> Union[Fattura, FatturaElettronicaSemplificata]:
35 | codecs = codec.Codecs()
36 | codec_cls = codecs.codec_from_filename(pathname)
37 | return codec_cls().load(pathname)
38 |
39 | @classmethod
40 | def add_subparser(cls, subparsers):
41 | name = getattr(cls, "NAME", None)
42 | if name is None:
43 | name = cls.__name__.lower()
44 | parser = subparsers.add_parser(name, help=cls.__doc__.strip())
45 | parser.set_defaults(app=cls)
46 | return parser
47 |
48 |
49 | class Diff(App):
50 | """
51 | show the difference between two fatture
52 | """
53 |
54 | NAME = "diff"
55 |
56 | def __init__(self, args):
57 | super().__init__(args)
58 | self.first = args.first
59 | self.second = args.second
60 |
61 | @classmethod
62 | def add_subparser(cls, subparsers):
63 | parser = super().add_subparser(subparsers)
64 | parser.add_argument("first", help="first input file (.xml or .xml.p7m)")
65 | parser.add_argument("second", help="second input file (.xml or .xml.p7m)")
66 | return parser
67 |
68 | def run(self):
69 | first = self.load_fattura(self.first)
70 | second = self.load_fattura(self.second)
71 | from a38.diff import Diff
72 |
73 | res = Diff()
74 | first.diff(res, second)
75 | if res.differences:
76 | for d in res.differences:
77 | print(d)
78 | return 1
79 |
80 |
81 | class Validate(App):
82 | """
83 | validate the contents of a fattura
84 | """
85 |
86 | NAME = "validate"
87 |
88 | def __init__(self, args):
89 | super().__init__(args)
90 | self.pathname = args.file
91 |
92 | @classmethod
93 | def add_subparser(cls, subparsers):
94 | parser = super().add_subparser(subparsers)
95 | parser.add_argument("file", help="input file (.xml or .xml.p7m)")
96 | return parser
97 |
98 | def run(self):
99 | f = self.load_fattura(self.pathname)
100 | from a38.validation import Validation
101 |
102 | res = Validation()
103 | f.validate(res)
104 | if res.warnings:
105 | for w in res.warnings:
106 | print(str(w), file=sys.stderr)
107 | if res.errors:
108 | for e in res.errors:
109 | print(str(e), file=sys.stderr)
110 | return 1
111 |
112 |
113 | class Exporter(App):
114 | def __init__(self, args):
115 | super().__init__(args)
116 | self.files = args.files
117 | self.output = args.output
118 | self.codec = self.get_codec()
119 |
120 | def get_codec(self) -> codec.Codec:
121 | """
122 | Instantiate the output codec to use for this exporter
123 | """
124 | raise NotImplementedError(
125 | f"{self.__class__.__name__}.get_codec is not implemented"
126 | )
127 |
128 | def write(self, f: models.Model, file: Union[IO[str], IO[bytes]]):
129 | self.codec.write_file(f, file)
130 |
131 | @contextlib.contextmanager
132 | def open_output(self):
133 | if self.output is None:
134 | if self.codec.binary:
135 | yield sys.stdout.buffer
136 | else:
137 | yield sys.stdout
138 | else:
139 | with open(self.output, "wb" if self.codec.binary else "wt") as out:
140 | yield out
141 |
142 | def run(self):
143 | with self.open_output() as out:
144 | for pathname in self.files:
145 | f = self.load_fattura(pathname)
146 | self.write(f, out)
147 |
148 | @classmethod
149 | def add_subparser(cls, subparsers):
150 | parser = super().add_subparser(subparsers)
151 | parser.add_argument(
152 | "-o", "--output", help="output file (default: standard output)"
153 | )
154 | parser.add_argument("files", nargs="+", help="input files (.xml or .xml.p7m)")
155 | return parser
156 |
157 |
158 | class ExportJSON(Exporter):
159 | """
160 | output a fattura in JSON
161 | """
162 |
163 | NAME = "json"
164 |
165 | def get_codec(self) -> codec.Codec:
166 | if self.args.indent == "no":
167 | indent = None
168 | else:
169 | try:
170 | indent = int(self.args.indent)
171 | except ValueError:
172 | raise Fail("--indent argument must be an integer on 'no'")
173 |
174 | return codec.JSON(indent=indent)
175 |
176 | @classmethod
177 | def add_subparser(cls, subparsers):
178 | parser = super().add_subparser(subparsers)
179 | parser.add_argument(
180 | "--indent",
181 | default="1",
182 | help="indentation space (default: 1, use 'no' for all in one line)",
183 | )
184 | return parser
185 |
186 |
187 | class ExportYAML(Exporter):
188 | """
189 | output a fattura in JSON
190 | """
191 |
192 | NAME = "yaml"
193 |
194 | def get_codec(self) -> codec.Codec:
195 | return codec.YAML()
196 |
197 |
198 | class ExportXML(Exporter):
199 | """
200 | output a fattura in XML
201 | """
202 |
203 | NAME = "xml"
204 |
205 | def get_codec(self) -> codec.Codec:
206 | return codec.XML()
207 |
208 |
209 | class ExportPython(Exporter):
210 | """
211 | output a fattura as Python code
212 | """
213 |
214 | NAME = "python"
215 |
216 | def get_codec(self) -> codec.Codec:
217 | namespace = self.args.namespace
218 | if namespace == "":
219 | namespace = False
220 |
221 | return codec.Python(namespace=namespace, unformatted=self.args.unformatted)
222 |
223 | @classmethod
224 | def add_subparser(cls, subparsers):
225 | parser = super().add_subparser(subparsers)
226 | parser.add_argument(
227 | "--namespace",
228 | default=None,
229 | help="namespace to use for the model classes (default: the module fully qualified name)",
230 | )
231 | parser.add_argument(
232 | "--unformatted",
233 | action="store_true",
234 | help="disable code formatting, outputting a single-line statement",
235 | )
236 | return parser
237 |
238 |
239 | class Edit(App):
240 | """
241 | Open a fattura for modification in a text editor
242 | """
243 |
244 | def __init__(self, args):
245 | super().__init__(args)
246 | if self.args.style == "yaml":
247 | self.edit_codec = codec.YAML()
248 | elif self.args.style == "python":
249 | self.edit_codec = codec.Python(loadable=True)
250 | else:
251 | raise Fail(f"Unsupported edit style {self.args.style!r}")
252 |
253 | def write_out(self, f):
254 | """
255 | Write a fattura, as much as possible over the file being edited
256 | """
257 | codecs = codec.Codecs()
258 | codec_cls = codecs.codec_from_filename(self.args.file)
259 | if codec_cls == codec.P7M:
260 | with open(self.args.file[:-4], "wb") as fd:
261 | codec_cls().write_file(f, fd)
262 | elif codec_cls.binary:
263 | with open(self.args.file, "wb") as fd:
264 | codec_cls().write_file(f, fd)
265 | else:
266 | with open(self.args.file, "wt") as fd:
267 | codec_cls().write_file(f, fd)
268 |
269 | def run(self):
270 | f = self.load_fattura(self.args.file)
271 | f1 = self.edit_codec.interactive_edit(f)
272 | if f1 is not None and f != f1:
273 | self.write_out(f1)
274 |
275 | @classmethod
276 | def add_subparser(cls, subparsers):
277 | parser = super().add_subparser(subparsers)
278 | parser.add_argument(
279 | "-s",
280 | "--style",
281 | default="yaml",
282 | help="editable representation to use, one of 'yaml' or 'python'. Default: $(default)s",
283 | )
284 | parser.add_argument("file", help="file to edit")
285 | return parser
286 |
287 |
288 | class Renderer(App):
289 | """
290 | Base class for CLI commands that render a Fattura
291 | """
292 |
293 | def __init__(self, args):
294 | from a38.render import HAVE_LXML
295 |
296 | if not HAVE_LXML:
297 | raise Fail("python3-lxml is needed for XSLT based rendering")
298 |
299 | super().__init__(args)
300 | self.stylesheet = args.stylesheet
301 | self.files = args.files
302 | self.output = args.output
303 | self.force = args.force
304 |
305 | from a38.render import XSLTTransform
306 |
307 | self.transform = XSLTTransform(self.stylesheet)
308 |
309 | def render(self, f, output: str):
310 | """
311 | Render the Fattura to the given destination file
312 | """
313 | raise NotImplementedError(
314 | self.__class__.__name__ + ".render is not implemented"
315 | )
316 |
317 | def run(self):
318 | for pathname in self.files:
319 | dirname = os.path.normpath(os.path.dirname(pathname))
320 | basename = os.path.basename(pathname)
321 | basename, ext = os.path.splitext(basename)
322 | output = self.output.format(dirname=dirname, basename=basename, ext=ext)
323 | if not self.force and os.path.exists(output):
324 | log.warning(
325 | "%s: output file %s already exists: skipped", pathname, output
326 | )
327 | else:
328 | log.info("%s: writing %s", pathname, output)
329 | f = self.load_fattura(pathname)
330 | self.render(f, output)
331 |
332 | @classmethod
333 | def add_subparser(cls, subparsers):
334 | parser = super().add_subparser(subparsers)
335 | parser.add_argument(
336 | "-f", "--force", action="store_true", help="overwrite existing output files"
337 | )
338 | default_output = "{dirname}/{basename}{ext}." + cls.NAME
339 | parser.add_argument(
340 | "-o",
341 | "--output",
342 | default=default_output,
343 | help="output file; use {dirname} for the source file path,"
344 | " {basename} for the source file name"
345 | " (default: '" + default_output + "'",
346 | )
347 | parser.add_argument(
348 | "stylesheet", help=".xsl/.xslt stylesheet file to use for rendering"
349 | )
350 | parser.add_argument("files", nargs="+", help="input files (.xml or .xml.p7m)")
351 | return parser
352 |
353 |
354 | class RenderHTML(Renderer):
355 | """
356 | render a Fattura as HTML using a .xslt stylesheet
357 | """
358 |
359 | NAME = "html"
360 |
361 | def render(self, f, output):
362 | html = self.transform(f)
363 | html.write(output)
364 |
365 |
366 | class RenderPDF(Renderer):
367 | """
368 | render a Fattura as PDF using a .xslt stylesheet
369 | """
370 |
371 | NAME = "pdf"
372 |
373 | def __init__(self, args):
374 | super().__init__(args)
375 | self.wkhtmltopdf = shutil.which("wkhtmltopdf")
376 | if self.wkhtmltopdf is None:
377 | raise Fail("wkhtmltopdf is needed for PDF rendering")
378 |
379 | def render(self, f, output: str):
380 | self.transform.to_pdf(self.wkhtmltopdf, f, output)
381 |
382 |
383 | class UpdateCAPath(App):
384 | """
385 | create/update an openssl CApath with CA certificates that can be used to
386 | validate digital signatures
387 | """
388 |
389 | NAME = "update_capath"
390 |
391 | def __init__(self, args):
392 | super().__init__(args)
393 | self.destdir = Path(args.destdir)
394 | self.remove_old = args.remove_old
395 |
396 | @classmethod
397 | def add_subparser(cls, subparsers):
398 | parser = super().add_subparser(subparsers)
399 | parser.add_argument("destdir", help="CA certificate directory to update")
400 | parser.add_argument(
401 | "--remove-old", action="store_true", help="remove old certificates"
402 | )
403 | return parser
404 |
405 | def run(self):
406 | from a38 import trustedlist as tl
407 |
408 | tl.update_capath(self.destdir, remove_old=self.remove_old)
409 |
410 |
411 | class Allegati(App):
412 | """
413 | Show the attachments in the fattura
414 | """
415 |
416 | def __init__(self, args: argparse.Namespace) -> None:
417 | super().__init__(args)
418 | self.pathname = args.file
419 | self.ids: set[int] = set()
420 | self.globs: list[re.Pattern] = []
421 | self.has_filter = False
422 | for pattern in self.args.attachments:
423 | self.has_filter = True
424 | if pattern.isdigit():
425 | self.ids.add(int(pattern))
426 | elif pattern.startswith("^"):
427 | self.globs.append(re.compile(pattern))
428 | else:
429 | self.globs.append(re.compile(fnmatch.translate(pattern)))
430 |
431 | @classmethod
432 | def add_subparser(cls, subparsers):
433 | parser = super().add_subparser(subparsers)
434 | parser.add_argument(
435 | "--extract", "-x", action="store_true", help="extract selected attachments"
436 | )
437 | parser.add_argument(
438 | "--json", action="store_true", help="show attachments in json format"
439 | )
440 | parser.add_argument(
441 | "--yaml", action="store_true", help="show attachments in yaml format"
442 | )
443 | parser.add_argument(
444 | "--output",
445 | "-o",
446 | action="store",
447 | help="destination file name (-o file) or directory (-o dir/)",
448 | )
449 | parser.add_argument("file", help="input file (.xml or .xml.p7m)")
450 | parser.add_argument(
451 | "attachments",
452 | nargs="*",
453 | help="IDs or names of attachments to extract. Shell-like wildcards allowed, or regexps if starting with ^",
454 | )
455 | return parser
456 |
457 | def match_allegato(self, index: int, allegato: fattura.Allegati) -> bool:
458 | """
459 | Check if the given allegato matches the attachments patterns
460 | """
461 | if not self.has_filter:
462 | return True
463 |
464 | for id in self.ids:
465 | if index == id:
466 | return True
467 |
468 | for regex in self.globs:
469 | if regex.match(allegato.nome_attachment):
470 | return True
471 |
472 | return False
473 |
474 | def print_allegato(self, index: int, allegato: fattura.Allegati) -> None:
475 | formato = allegato.formato_attachment or "-"
476 | print(f"{index:02d}: {formato} {allegato.nome_attachment}")
477 | if allegato.descrizione_attachment:
478 | print(f" {allegato.descrizione_attachment}")
479 |
480 | def run(self):
481 | f = self.load_fattura(self.pathname)
482 | selected: list[tuple[int, fattura.Allegati]] = []
483 | index = 1
484 | for body in f.fattura_elettronica_body:
485 | for allegato in body.allegati:
486 | if self.match_allegato(index, allegato):
487 | selected.append((index, allegato))
488 | index += 1
489 |
490 | if self.args.json or self.args.yaml:
491 | output = []
492 | for index, allegato in selected:
493 | jsonable = {"index": index}
494 | jsonable.update(allegato.to_jsonable())
495 | jsonable.pop("attachment", None)
496 | output.append(jsonable)
497 |
498 | if self.args.json:
499 | import json
500 |
501 | json.dump(output, sys.stdout, indent=2)
502 | print()
503 | else:
504 | import yaml
505 |
506 | yaml.dump(
507 | output,
508 | stream=sys.stdout,
509 | default_flow_style=False,
510 | sort_keys=False,
511 | allow_unicode=True,
512 | explicit_start=True,
513 | Dumper=yaml.CDumper,
514 | )
515 | elif self.args.extract:
516 | destname: Optional[str]
517 | destdir: str
518 |
519 | if self.args.output:
520 | if os.path.isdir(self.args.output) or self.args.output.endswith(os.sep):
521 | destname = None
522 | destdir = self.args.output
523 | else:
524 | destname = self.args.output
525 | destdir = "."
526 | else:
527 | destname = None
528 | destdir = "."
529 |
530 | if destname is not None and len(selected) > 1:
531 | raise Fail(
532 | "there are multiple attachment to save, and--output points to a single file name"
533 | )
534 |
535 | os.makedirs(destdir, exist_ok=True)
536 |
537 | for index, allegato in selected:
538 | if destname is None:
539 | destname = os.path.basename(allegato.nome_attachment)
540 | dest = os.path.join(destdir, destname)
541 | log.info("Extracting %s to %s", allegato.nome_attachment, dest)
542 | with open(dest, "wb") as fd:
543 | fd.write(allegato.attachment)
544 | else:
545 | for index, allegato in selected:
546 | self.print_allegato(index, allegato)
547 |
548 |
549 | def main():
550 | parser = argparse.ArgumentParser(description="Handle fattura elettronica files")
551 | parser.add_argument("--verbose", "-v", action="store_true", help="verbose output")
552 | parser.add_argument("--debug", action="store_true", help="debug output")
553 |
554 | subparsers = parser.add_subparsers(help="actions", required=True)
555 | subparsers.dest = "command"
556 |
557 | ExportJSON.add_subparser(subparsers)
558 | ExportYAML.add_subparser(subparsers)
559 | ExportXML.add_subparser(subparsers)
560 | ExportPython.add_subparser(subparsers)
561 | Edit.add_subparser(subparsers)
562 | Diff.add_subparser(subparsers)
563 | Validate.add_subparser(subparsers)
564 | RenderHTML.add_subparser(subparsers)
565 | RenderPDF.add_subparser(subparsers)
566 | UpdateCAPath.add_subparser(subparsers)
567 | Allegati.add_subparser(subparsers)
568 |
569 | args = parser.parse_args()
570 |
571 | log_format = "%(levelname)s %(message)s"
572 | level = logging.WARN
573 | if args.debug:
574 | level = logging.DEBUG
575 | elif args.verbose:
576 | level = logging.INFO
577 | logging.basicConfig(level=level, stream=sys.stderr, format=log_format)
578 |
579 | app = args.app(args)
580 | res = app.run()
581 | if isinstance(res, int):
582 | sys.exit(res)
583 |
584 |
585 | if __name__ == "__main__":
586 | try:
587 | main()
588 | except Fail as e:
589 | print(e, file=sys.stderr)
590 | sys.exit(1)
591 | except Exception:
592 | log.exception("uncaught exception")
593 |
--------------------------------------------------------------------------------
/a38tool.md:
--------------------------------------------------------------------------------
1 | # `a38tool`
2 |
3 | General command line help:
4 |
5 | ```text
6 | $ a38tool --help
7 | usage: a38tool [-h] [--verbose] [--debug]
8 | {json,xml,python,diff,validate,html,pdf,update_capath} ...
9 |
10 | Handle fattura elettronica files
11 |
12 | positional arguments:
13 | {json,xml,python,diff,validate,html,pdf,update_capath}
14 | actions
15 | json output a fattura in JSON
16 | xml output a fattura in XML
17 | python output a fattura as Python code
18 | diff show the difference between two fatture
19 | validate validate the contents of a fattura
20 | html render a Fattura as HTML using a .xslt stylesheet
21 | pdf render a Fattura as PDF using a .xslt stylesheet
22 | update_capath create/update an openssl CApath with CA certificates
23 | that can be used to validate digital signatures
24 |
25 | optional arguments:
26 | -h, --help show this help message and exit
27 | --verbose, -v verbose output
28 | --debug debug output
29 | ```
30 |
31 | ### Difference between two fatture
32 |
33 | ```text
34 | $ a38tool diff --help
35 | usage: a38tool diff [-h] first second
36 |
37 | positional arguments:
38 | first first input file (.xml or .xml.p7m)
39 | second second input file (.xml or .xml.p7m)
40 |
41 | optional arguments:
42 | -h, --help show this help message and exit
43 | ```
44 |
45 | Example:
46 |
47 | ```text
48 | $ a38tool diff doc/IT01234567890_FPR01.xml doc/IT01234567890_FPR02.xml
49 | fattura_elettronica_header.dati_trasmissione.codice_destinatario: first: ABC1234, second: 0000000
50 | fattura_elettronica_header.dati_trasmissione.pec_destinatario: first is not set
51 | fattura_elettronica_header.cedente_prestatore.dati_anagrafici.regime_fiscale: first: RF19, second: RF01
52 | fattura_elettronica_header.cessionario_committente.dati_anagrafici.anagrafica.denominazione: first: DITTA BETA, second: …
53 | fattura_elettronica_body.0.dati_generali.dati_contratto: second is not set
54 | fattura_elettronica_body.0.dati_beni_servizi.dettaglio_linee.0.descrizione: first: DESCRIZIONE DELLA FORNITURA, second: …
55 | …
56 | $ echo $?
57 | 1
58 | ```
59 |
60 | ### Validate a fattura
61 |
62 | ```text
63 | $ a38tool validate --help
64 | usage: a38tool validate [-h] file
65 |
66 | positional arguments:
67 | file input file (.xml or .xml.p7m)
68 |
69 | optional arguments:
70 | -h, --help show this help message and exit
71 | ```
72 |
73 | Example:
74 |
75 | ```text
76 | $ a38tool validate doc/IT01234567890_FPR01.xml
77 | fattura_elettronica_body.0.dati_beni_servizi.unita_misura: field must be present when quantita is set
78 | $ echo $?
79 | 1
80 | ```
81 |
82 | ### Convert a fattura to JSON
83 |
84 | ```text
85 | $ a38tool json --help
86 | usage: a38tool json [-h] [-o OUTPUT] [--indent INDENT] files [files ...]
87 |
88 | positional arguments:
89 | files input files (.xml or .xml.p7m)
90 |
91 | optional arguments:
92 | -h, --help show this help message and exit
93 | -o OUTPUT, --output OUTPUT
94 | output file (default: standard output)
95 | --indent INDENT indentation space (default: 1, use 'no' for all in one
96 | line)
97 | ```
98 |
99 | Example:
100 |
101 | ```text
102 | $ a38tool json doc/IT01234567890_FPR02.xml
103 | {
104 | "fattura_elettronica_header": {
105 | "dati_trasmissione": {
106 | "id_trasmittente": {
107 | "id_paese": "IT",
108 | "id_codice": "01234567890"
109 | …
110 | ```
111 |
112 | Use `--indent=no` to output a json per line, making it easy to separate reparse
113 | a group of JSON fatture:
114 |
115 | ```text
116 | $ a38tool json --indent=no doc/*.xml
117 | {"fattura_elettronica_header": {"dati_tr…
118 | {"fattura_elettronica_header": {"dati_tr…
119 | {"fattura_elettronica_header": {"dati_tr…
120 | …
121 | ```
122 |
123 | ### Extract XML from a `.p7m` signed fattura
124 |
125 | ```text
126 | $ a38tool xml --help
127 | usage: a38tool xml [-h] [-o OUTPUT] files [files ...]
128 |
129 | positional arguments:
130 | files input files (.xml or .xml.p7m)
131 |
132 | optional arguments:
133 | -h, --help show this help message and exit
134 | -o OUTPUT, --output OUTPUT
135 | output file (default: standard output)
136 | ```
137 |
138 | ### Generate Python code
139 |
140 | You can convert a fattura to Python code: this is a quick way to start writing
141 | a software that generates fatture similar to an existing one.
142 |
143 | ```text
144 | $ a38tool python --help
145 | usage: a38tool python [-h] [-o OUTPUT] [--namespace NAMESPACE] [--unformatted]
146 | files [files ...]
147 |
148 | positional arguments:
149 | files input files (.xml or .xml.p7m)
150 |
151 | optional arguments:
152 | -h, --help show this help message and exit
153 | -o OUTPUT, --output OUTPUT
154 | output file (default: standard output)
155 | --namespace NAMESPACE
156 | namespace to use for the model classes (default: the
157 | module fully qualified name)
158 | --unformatted disable code formatting, outputting a single-line
159 | statement
160 | ```
161 |
162 | Example:
163 |
164 | ```text
165 | $ a38tool python doc/IT01234567890_FPR02.xml
166 | a38.fattura.FatturaPrivati12(
167 | fattura_elettronica_header=a38.fattura.FatturaElettronicaHeader(
168 | dati_trasmissione=a38.fattura.DatiTrasmissione(
169 | id_trasmittente=a38.fattura.IdTrasmittente(
170 | id_paese='IT', id_codice='01234567890'),
171 | progressivo_invio='00001',
172 | …
173 | ```
174 |
175 |
176 | ### Render to HTML or PDF
177 |
178 | You can use a .xslt file to render a fattura to HTML or PDF.
179 |
180 | ```text
181 | $ a38tool html --help
182 | usage: a38tool html [-h] [-f] [-o OUTPUT] stylesheet files [files ...]
183 |
184 | positional arguments:
185 | stylesheet .xsl/.xslt stylesheet file to use for rendering
186 | files input files (.xml or .xml.p7m)
187 |
188 | optional arguments:
189 | -h, --help show this help message and exit
190 | -f, --force overwrite existing output files
191 | -o OUTPUT, --output OUTPUT
192 | output file; use {dirname} for the source file path,
193 | {basename} for the source file name (default:
194 | '{dirname}/{basename}{ext}.html'
195 | ```
196 |
197 | Example:
198 |
199 | ```text
200 | $ a38tool -v html -f doc/fatturaordinaria_v1.2.1.xsl doc/IT01234567890_FPR02.xml
201 | INFO doc/IT01234567890_FPR02.xml: writing doc/IT01234567890_FPR02.xml.html
202 | ```
203 |
204 | ```text
205 | $ a38tool -v pdf -f doc/fatturaordinaria_v1.2.1.xsl doc/IT01234567890_FPR02.xml
206 | INFO doc/IT01234567890_FPR02.xml: writing doc/IT01234567890_FPR02.xml.pdf
207 | ```
208 |
209 |
--------------------------------------------------------------------------------
/doc/.gitignore:
--------------------------------------------------------------------------------
1 | /IT01234567890_FP*.xml
2 | /*.pdf
3 | /*.xls
4 | /*.xsd
5 | /*.xsl
6 |
--------------------------------------------------------------------------------
/doc/README.md:
--------------------------------------------------------------------------------
1 | Run the `download-docs` script to populate this directory with official
2 | documentation. Note that the URLS may change, and the script may need to be
3 | updated from time to time.
4 |
5 | Normativa:
6 |
7 | Formato:
8 |
9 | Assocons validator:
10 |
11 | Agenzia delle Entrate validator:
12 |
13 | # TODO
14 |
15 | Get documentation from
16 | (it covers Fattura Elettronica Semplificata)
17 |
--------------------------------------------------------------------------------
/document-a38:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | import argparse
3 | import logging
4 | import subprocess
5 | import os
6 | import re
7 | import sys
8 |
9 |
10 | log = logging.getLogger("document-a38")
11 |
12 |
13 | class Fail(Exception):
14 | pass
15 |
16 |
17 | def sample_output(cmd, max_lines=None, max_line_length=None, returncode=0):
18 | res = subprocess.run("./" + cmd, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True)
19 | if res.returncode != returncode:
20 | raise RuntimeError(f"{cmd} return code is {res.returncode} instead of {returncode}")
21 | lines = []
22 | for idx, line in enumerate(res.stdout.splitlines()):
23 | if max_lines is not None and idx >= max_lines:
24 | lines.append("…")
25 | break
26 | if max_line_length is not None and len(line) > max_line_length:
27 | lines.append(line[:max_line_length] + "…")
28 | else:
29 | lines.append(line)
30 | return lines
31 |
32 |
33 | def generate_output(mo):
34 | cmd = mo.group("cmd")
35 | output = mo.group("output")
36 | returncode = mo.group("result")
37 | if returncode is not None:
38 | returncode = int(returncode)
39 | else:
40 | returncode = 0
41 |
42 | # Autodetect max_lines and max_line_length
43 | lines = output.splitlines()
44 |
45 | max_lines = None
46 | if lines[-1] == "…":
47 | max_lines = len(lines) - 1
48 |
49 | max_line_length = None
50 | for l in lines:
51 | if len(l) > 1 and l[-1] == "…":
52 | max_line_length = len(l) - 1
53 | break
54 |
55 | lines = [
56 | "```text",
57 | f"$ {cmd}",
58 | ]
59 | lines.extend(sample_output(cmd, max_lines, max_line_length, returncode))
60 | if returncode != 0:
61 | lines.append("$ echo $?")
62 | lines.append(str(returncode))
63 | lines.append("```")
64 |
65 | return "\n".join(lines) + "\n"
66 |
67 |
68 | def process_md(fname):
69 | with open(fname, "rt") as fd:
70 | content = fd.read()
71 |
72 | # print(re.search(
73 | # r"```text\s*\n"
74 | # r"(?P