├── .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 | ![full workflow](https://github.com/Truelite/python-a38/actions/workflows/py.yml/badge.svg) 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.+?)" 75 | # r"```" 76 | # , content, re.S)) 77 | 78 | new_content = re.sub( 79 | r"```text[ \t]*\n" 80 | r"\$ (?Pa38tool [^\n]+)\n" 81 | r"(?P.+?)" 82 | r"(?:\$ echo \$\?\s*\n(?P\d+)\s*\n)?" 83 | r"```[ \t]*\n", generate_output, content, flags=re.S) 84 | 85 | if new_content == content: 86 | return False 87 | 88 | with open(fname, "wt") as fd: 89 | fd.write(new_content) 90 | return True 91 | 92 | 93 | def main(): 94 | parser = argparse.ArgumentParser(description="Update a38tool examples in markdown documentation") 95 | parser.add_argument("--verbose", "-v", action="store_true", help="verbose output") 96 | parser.add_argument("--debug", action="store_true", help="debug output") 97 | 98 | args = parser.parse_args() 99 | 100 | log_format = "%(asctime)-15s %(levelname)s %(message)s" 101 | level = logging.WARN 102 | if args.debug: 103 | level = logging.DEBUG 104 | elif args.verbose: 105 | level = logging.INFO 106 | logging.basicConfig(level=level, stream=sys.stderr, format=log_format) 107 | 108 | for fn in os.listdir("."): 109 | if not fn.endswith(".md"): 110 | continue 111 | if process_md(fn): 112 | log.warning("%s: updated", fn) 113 | else: 114 | log.info("%s: unchanged", fn) 115 | 116 | 117 | if __name__ == "__main__": 118 | try: 119 | main() 120 | except Fail as e: 121 | print(e, file=sys.stderr) 122 | sys.exit(1) 123 | except Exception: 124 | log.exception("uncaught exception") 125 | -------------------------------------------------------------------------------- /download-docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import logging 4 | import sys 5 | import requests 6 | import re 7 | import urllib.parse 8 | import os 9 | from lxml import etree as ET 10 | 11 | log = logging.getLogger("download_docs") 12 | 13 | DOCS = [ 14 | r"/Schema_del_file_xml.+\.xsd$", 15 | r"/Specifiche_tecniche.+\.pdf$", 16 | r"/Rappresentazione_tabellare_del_tracciato.+\.(?:pdf|xls)$", 17 | r"/fatturaPA.+\.xsl$", 18 | r"/fatturaordinaria.+\.xsl$" 19 | r"/changelog_formato\.pdf$", 20 | r"/Suggerimenti_Compilazione.+\.pdf$", 21 | r"/fatturapa.+\.xsl$", 22 | r"/fatturaordinaria.+\.xsl$", 23 | r"/Elenco_Controlli.+\.pdf$", 24 | ] 25 | 26 | EXAMPLES = [ 27 | r"/IT01234567890_FP.+\.xml", 28 | ] 29 | 30 | 31 | def get_urls(index_url): 32 | index = requests.get(index_url) 33 | parser = ET.XMLParser(recover=True) 34 | root = ET.fromstring(index.text, parser) 35 | re_docs = [re.compile(r) for r in DOCS] 36 | re_examples = [re.compile(r) for r in EXAMPLES] 37 | links = [] 38 | for a in root.iter("a"): 39 | href = a.attrib.get("href") 40 | if href is None: 41 | continue 42 | # There seem to be various wrong links to this file, so we ignore 43 | # them 44 | if "IT01234567890_11111" in href: 45 | continue 46 | links.append(href) 47 | for l in links: 48 | for r in re_docs: 49 | if r.search(l): 50 | yield {"type": "doc", "href": l} 51 | for r in re_examples: 52 | if r.search(l): 53 | yield {"type": "example", "href": l} 54 | 55 | 56 | def download(index_url): 57 | for el in get_urls(index_url): 58 | url = urllib.parse.urljoin(index_url, el["href"]) 59 | parsed = urllib.parse.urlparse(url) 60 | filename = os.path.basename(parsed.path) 61 | if el["type"] == "doc": 62 | dest = os.path.join("doc", filename) 63 | elif el["type"] == "example": 64 | dest = os.path.join("doc", filename) 65 | if os.path.exists(dest): 66 | log.info("%s: already downloaded", dest) 67 | continue 68 | res = requests.get(url, stream=True) 69 | with open(dest, 'wb') as fd: 70 | for chunk in res.iter_content(chunk_size=128): 71 | fd.write(chunk) 72 | log.info("%s: downloading", dest) 73 | 74 | 75 | class Fail(Exception): 76 | pass 77 | 78 | 79 | def main(): 80 | parser = argparse.ArgumentParser(description="download documents and examples from www.fatturapa.gov.it") 81 | parser.add_argument("--verbose", "-v", action="store_true", help="verbose output") 82 | parser.add_argument("--debug", action="store_true", help="debug output") 83 | 84 | args = parser.parse_args() 85 | 86 | log_format = "%(asctime)-15s %(levelname)s %(message)s" 87 | level = logging.WARN 88 | if args.debug: 89 | level = logging.DEBUG 90 | elif args.verbose: 91 | level = logging.INFO 92 | logging.basicConfig(level=level, stream=sys.stderr, format=log_format) 93 | 94 | download("https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/") 95 | 96 | 97 | if __name__ == "__main__": 98 | try: 99 | main() 100 | except Fail as e: 101 | print(e, file=sys.stderr) 102 | sys.exit(1) 103 | except Exception: 104 | log.exception("uncaught exception") 105 | -------------------------------------------------------------------------------- /publiccode.yml: -------------------------------------------------------------------------------- 1 | # This repository adheres to the publiccode.yml standard by including this 2 | # metadata file that makes public software easily discoverable. 3 | # More info at https://github.com/italia/publiccode.yml 4 | 5 | publiccodeYmlVersion: '0.2' 6 | name: A38 7 | url: 'https://github.com/Truelite/python-a38.git' 8 | softwareVersion: 0.1.8 9 | releaseDate: '2024-01-10' 10 | inputTypes: 11 | - text/xml 12 | outputTypes: 13 | - text/html 14 | - text/xml 15 | - text/x-python 16 | - application/pdf 17 | - application/json 18 | - application/x-x509-ca-cert 19 | platforms: 20 | - linux 21 | - windows 22 | - ios 23 | categories: 24 | - billing-and-invoicing 25 | developmentStatus: beta 26 | softwareType: library 27 | dependsOn: 28 | open: 29 | - name: Python 30 | versionMin: '3.11' 31 | optional: false 32 | maintenance: 33 | type: internal 34 | contacts: 35 | - name: Truelite srl 36 | email: a38@truelite.it 37 | legal: 38 | license: Apache-2.0 39 | mainCopyrightOwner: Truelite srl 40 | repoOwner: Truelite srl 41 | intendedAudience: 42 | countries: 43 | - it 44 | localisation: 45 | localisationReady: false 46 | availableLanguages: 47 | - en 48 | description: 49 | en: 50 | shortDescription: | 51 | parse and generate Italian Fattura Elettronica 52 | longDescription: > 53 | This library implements a declarative data model similar to Django models, 54 | that is designed to describe, validate, serialize and parse Italian 55 | Fattura Elettronica data. 56 | 57 | Only part of the specification is implemented, with more added as needs 58 | will arise. Anyone is welcome to implement the missing pieces they need 59 | and send a pull request: the idea is to have a good, free (as in freedom) 60 | library to make billing in Italy with Python easier for everyone. 61 | 62 | The library can generate various kinds of fatture that pass validation, 63 | and can parse all the example XML files distributed by fatturapa.gov.it 64 | features: 65 | - | 66 | Implement the Italian Fattura Elettronica in a declarative data model 67 | genericName: Library 68 | -------------------------------------------------------------------------------- /requirements-devops.txt: -------------------------------------------------------------------------------- 1 | isort 2 | autoflake 3 | flake8 4 | bandit 5 | -------------------------------------------------------------------------------- /requirements-lib.txt: -------------------------------------------------------------------------------- 1 | python-dateutil 2 | pytz 3 | asn1crypto 4 | defusedxml 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | namespace_packages = True 3 | mypy_path = stubs 4 | 5 | [flake8] 6 | max-line-length = 120 7 | 8 | [options] 9 | python_requires = >= 3.11 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | with open("README.md", "r") as fp: 5 | long_description = fp.read() 6 | 7 | 8 | def parse_requirements(filename): 9 | line_iter = (line.strip() for line in open(filename)) 10 | return [line for line in line_iter if line and not line.startswith("#")] 11 | 12 | 13 | setup( 14 | name="a38", 15 | version="0.1.8", 16 | description="parse and generate Italian Fattura Elettronica", 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | author="Enrico Zini", 20 | author_email="enrico@truelite.it", 21 | url="https://github.com/Truelite/python-a38/", 22 | license="https://www.apache.org/licenses/LICENSE-2.0.html", 23 | packages=["a38"], 24 | scripts=["a38tool"], 25 | install_requires=parse_requirements("requirements-lib.txt"), 26 | test_requires=parse_requirements("requirements-lib.txt"), 27 | extras_require={ 28 | "formatted_python": ["yapf"], 29 | "html": ["lxml"], 30 | "cacerts": ["requests"], 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /stubs/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Truelite/python-a38/a6877e77dfc5cb8fb2354ce70f3cb920510fd99a/stubs/__init__.pyi -------------------------------------------------------------------------------- /stubs/dateutil/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Truelite/python-a38/a6877e77dfc5cb8fb2354ce70f3cb920510fd99a/stubs/dateutil/__init__.pyi -------------------------------------------------------------------------------- /stubs/dateutil/parser.pyi: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | def isoparse(arg: str) -> datetime.datetime: ... 5 | -------------------------------------------------------------------------------- /test-coverage: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -u 3 | if [ $# -eq 0 ] 4 | then 5 | set -x 6 | eatmydata nose2-3 -C --coverage-report html 7 | else 8 | set -x 9 | eatmydata nose2-3 "$@" 10 | fi 11 | -------------------------------------------------------------------------------- /tests/data/dati_trasporto.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IT 6 | 01234567890 7 | 8 | 00000 9 | FPR12 10 | FUFUFUF 11 | 12 | local_part@domain.it 13 | 14 | 15 | 16 | 17 | 18 | IT 19 | 09876543210 20 | 21 | 22 | Test denominazione 23 | 24 | RF01 25 | 26 | 27 | test Address, 1 28 | 12345 29 | Test Comune 30 | BO 31 | IT 32 | 33 | 34 | 35 | 36 | 37 | IT 38 | 01234567891 39 | 40 | 41 | Test Denominazione 2 42 | 43 | 44 | 45 | test Address, 2 46 | 54321 47 | Test Comune 1 48 | BO 49 | IT 50 | 51 | 52 | 53 | 54 | 55 | 56 | TD01 57 | EUR 58 | 2019-01-01 59 | 1 60 | 123.45 61 | Test Causale 62 | 63 | RT04 64 | 70.00 65 | 1.00 66 | ZO 67 | 68 | 69 | RT02 70 | 805.00 71 | 11.50 72 | Q 73 | 74 | 75 | 76 | 1 77 | a1 78 | 2018-12-31 79 | 1 80 | 81 | 82 | 2 83 | a1 84 | 2018-12-31 85 | 2 86 | 87 | 88 | 3 89 | a1 90 | 2018-12-31 91 | 3 92 | 93 | 94 | 4 95 | a1 96 | 2018-12-31 97 | 4 98 | 99 | 100 | 5 101 | a1 102 | 2018-12-31 103 | 5 104 | 105 | 106 | 123 456 107 | 2019-01-02 108 | 1 109 | 2 110 | 3 111 | 4 112 | 5 113 | 114 | 115 | AAA 116 | 117 | 118 | 119 | 120 | 1 121 | Linea 1 122 | 1 123 | NR 124 | 1 125 | 1 126 | 22.00 127 | 128 | 129 | 2 130 | Linea 2 131 | 1 132 | NR 133 | 1 134 | 1 135 | 22.00 136 | 137 | 138 | 3 139 | Linea 3 140 | 1 141 | NR 142 | 1 143 | 1 144 | 22.00 145 | 146 | 147 | 4 148 | Linea 4 149 | 1 150 | NR 151 | 1 152 | 1 153 | 22.00 154 | 155 | 156 | 5 157 | Linea 5 158 | 1 159 | NR 160 | 1 161 | 1 162 | 22.00 163 | 164 | 165 | 22.00 166 | 5 167 | 1.1 168 | I 169 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /tests/data/test.txt.p7m: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Truelite/python-a38/a6877e77dfc5cb8fb2354ce70f3cb920510fd99a/tests/data/test.txt.p7m -------------------------------------------------------------------------------- /tests/data/unicode.xml: -------------------------------------------------------------------------------- 1 | 2 | IT10293847561BAEIE+F8OFPR12AAAAAAIT01234567890Astérix le GauloisRF01IT76543210987جعفر محمد بن موسی خوارزمیTD01EUR2019-02-14181.13🅲🅰🆄🆂🅰🅻🅴1🎂🥮🍰🧁2.00kg25.5051.0022.00 3 | -------------------------------------------------------------------------------- /tests/test_fattura.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | import tempfile 4 | from decimal import Decimal 5 | from unittest import SkipTest, TestCase 6 | 7 | import a38 8 | from a38 import codec, validation 9 | 10 | 11 | class TestFatturaMixin: 12 | def assert_validates(self, value, warnings=[], errors=[]): 13 | val = validation.Validation() 14 | value.validate(val) 15 | self.assertEqual([str(x) for x in val.warnings], warnings) 16 | self.assertEqual([str(x) for x in val.errors], errors) 17 | 18 | 19 | class TestAnagrafica(TestFatturaMixin, TestCase): 20 | def test_validation(self): 21 | a = a38.Anagrafica() 22 | 23 | self.assert_validates(a, errors=[ 24 | 'nome: nome and cognome, or denominazione, must be set', 25 | 'cognome: nome and cognome, or denominazione, must be set', 26 | 'denominazione: nome and cognome, or denominazione, must be set', 27 | ]) 28 | 29 | a.nome = "Test" 30 | self.assert_validates(a, errors=[ 31 | "cognome: nome and cognome must both be set if denominazione is empty", 32 | ]) 33 | 34 | a.cognome = "Test1" 35 | self.assert_validates(a) 36 | 37 | a.nome = None 38 | self.assert_validates(a, errors=[ 39 | "nome: nome and cognome must both be set if denominazione is empty", 40 | ]) 41 | 42 | a.denominazione = "Test Test1" 43 | self.assert_validates(a, errors=[ 44 | "cognome: cognome must not be set if denominazione is not empty", 45 | ]) 46 | 47 | a.denominazione = "Test Test1" 48 | a.nome = "Test" 49 | self.assert_validates(a, errors=[ 50 | "nome: nome and cognome must not be set if denominazione is not empty", 51 | "cognome: nome and cognome must not be set if denominazione is not empty", 52 | ]) 53 | 54 | a.cognome = None 55 | self.assert_validates(a, errors=[ 56 | "nome: nome must not be set if denominazione is not empty", 57 | ]) 58 | 59 | a.nome = None 60 | self.assert_validates(a) 61 | 62 | 63 | class TestDatiTrasmissione(TestFatturaMixin, TestCase): 64 | def test_validation(self): 65 | dt = a38.DatiTrasmissione( 66 | a38.IdTrasmittente("ID", "1234567890"), 67 | "12345", "FPR12") 68 | 69 | self.assert_validates(dt, errors=[ 70 | # "codice_destinatario: one of codice_destinatario or pec_destinatario must be set", 71 | # "pec_destinatario: one of codice_destinatario or pec_destinatario must be set", 72 | "codice_destinatario: [00426] pec_destinatario has no value while codice_destinatario has value 0000000", 73 | "pec_destinatario: [00426] pec_destinatario has no value while codice_destinatario has value 0000000", 74 | ]) 75 | 76 | dt.codice_destinatario = "FUFUFU" 77 | self.assert_validates(dt, errors=[ 78 | "codice_destinatario: [00427] codice_destinatario has 6 characters on a Fattura Privati", 79 | ]) 80 | 81 | dt.codice_destinatario = "FUFUFUF" 82 | self.assert_validates(dt) 83 | 84 | dt.pec_destinatario = "local_part@example.org" 85 | self.assert_validates(dt, errors=[ 86 | "codice_destinatario: [00426] pec_destinatario has value while codice_destinatario has value 0000000", 87 | "pec_destinatario: [00426] pec_destinatario has value while codice_destinatario has value 0000000", 88 | ]) 89 | 90 | dt.codice_destinatario = None 91 | self.assert_validates(dt) 92 | 93 | 94 | class TestDatiBeniServizi(TestFatturaMixin, TestCase): 95 | def test_add_dettaglio_linee(self): 96 | o = a38.DatiBeniServizi() 97 | o.add_dettaglio_linee( 98 | descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, aliquota_iva=22) 99 | o.add_dettaglio_linee( 100 | descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", aliquota_iva=22) 101 | self.assertEqual(len(o.dettaglio_linee), 2) 102 | self.assertEqual(o.dettaglio_linee[0], a38.DettaglioLinee( 103 | numero_linea=1, descrizione="Line 1", quantita=2, unita_misura="m²", 104 | prezzo_unitario=7, prezzo_totale=14, aliquota_iva=22)) 105 | self.assertEqual(o.dettaglio_linee[1], a38.DettaglioLinee( 106 | numero_linea=2, descrizione="Line 2", quantita=1, unita_misura="A", 107 | prezzo_unitario="0.4", prezzo_totale="0.4", aliquota_iva=22)) 108 | 109 | def test_add_dettaglio_linee_without_quantita(self): 110 | o = a38.DatiBeniServizi() 111 | o.add_dettaglio_linee(descrizione="Line 1", prezzo_unitario=7, aliquota_iva=22) 112 | self.assertEqual(len(o.dettaglio_linee), 1) 113 | self.assertEqual( 114 | o.dettaglio_linee[0], 115 | a38.DettaglioLinee(1, descrizione="Line 1", prezzo_unitario=7, prezzo_totale=7, aliquota_iva=22)) 116 | 117 | def test_build_dati_riepilogo(self): 118 | o = a38.DatiBeniServizi() 119 | o.add_dettaglio_linee(descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, aliquota_iva=22) 120 | o.add_dettaglio_linee( 121 | descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", aliquota_iva=22) 122 | o.add_dettaglio_linee( 123 | descrizione="Line 3", quantita="3.5", unita_misura="A", prezzo_unitario="0.5", aliquota_iva=10) 124 | o.build_dati_riepilogo() 125 | 126 | self.assertEqual(len(o.dati_riepilogo), 2) 127 | self.assertEqual( 128 | o.dati_riepilogo[0], 129 | a38.DatiRiepilogo(aliquota_iva="10", imponibile_importo="1.75", imposta="0.175", esigibilita_iva="I")) 130 | self.assertEqual( 131 | o.dati_riepilogo[1], 132 | a38.DatiRiepilogo(aliquota_iva="22", imponibile_importo="14.40", imposta="3.168", esigibilita_iva="I")) 133 | 134 | def test_build_dati_riepilogo_natura(self): 135 | self.maxDiff = None 136 | 137 | common_args = {"descrizione": "Line", "quantita": 1, "unita_misura": "N"} 138 | 139 | o = a38.DatiBeniServizi() 140 | o.add_dettaglio_linee(prezzo_unitario=7, aliquota_iva=22, natura="N1", **common_args) 141 | o.add_dettaglio_linee(prezzo_unitario="1", aliquota_iva=22, natura="N1", **common_args) 142 | o.add_dettaglio_linee(prezzo_unitario="3.5", aliquota_iva=10, natura="N6", **common_args) 143 | o.add_dettaglio_linee(prezzo_unitario="3.5", aliquota_iva=0, natura="N2.1", **common_args) 144 | o.add_dettaglio_linee(prezzo_unitario="7.5", aliquota_iva=0, natura="N2.1", **common_args) 145 | o.add_dettaglio_linee(prezzo_unitario="5", aliquota_iva=0, natura="N3.1", **common_args) 146 | o.build_dati_riepilogo() 147 | 148 | self.assertEqual(len(o.dati_riepilogo), 4) 149 | self.assertEqual( 150 | o.dati_riepilogo[0], 151 | a38.DatiRiepilogo( 152 | aliquota_iva="0", imponibile_importo="11", imposta="0", esigibilita_iva="I", natura="N2.1")) 153 | self.assertEqual( 154 | o.dati_riepilogo[1], 155 | a38.DatiRiepilogo( 156 | aliquota_iva="0", imponibile_importo="5", imposta="0", esigibilita_iva="I", natura="N3.1")) 157 | self.assertEqual( 158 | o.dati_riepilogo[2], 159 | a38.DatiRiepilogo( 160 | aliquota_iva="10", imponibile_importo="3.5", imposta="0.35", esigibilita_iva="I", natura="N6")) 161 | self.assertEqual( 162 | o.dati_riepilogo[3], 163 | a38.DatiRiepilogo( 164 | aliquota_iva="22", imponibile_importo="8", imposta="1.76", esigibilita_iva="I", natura="N1")) 165 | 166 | def test_build_dati_riepilogo_natura_issue33(self): 167 | self.maxDiff = None 168 | 169 | o = a38.DatiBeniServizi() 170 | o.add_dettaglio_linee( 171 | descrizione="Bollo", quantita=1, unita_misura="EUR", 172 | prezzo_unitario="2", aliquota_iva="0.00", natura="N1") 173 | o.build_dati_riepilogo() 174 | 175 | self.assertEqual(o.dati_riepilogo[0].natura, "N1") 176 | 177 | from a38.validation import Validation 178 | res = Validation() 179 | o.validate(res) 180 | self.assertEqual(res.warnings, []) 181 | self.assertEqual(res.errors, []) 182 | 183 | 184 | class TestFatturaElettronicaBody(TestFatturaMixin, TestCase): 185 | def test_build_importo_totale_documento(self): 186 | o = a38.FatturaElettronicaBody() 187 | o.dati_beni_servizi.add_dettaglio_linee( 188 | descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, aliquota_iva=22) 189 | o.dati_beni_servizi.add_dettaglio_linee( 190 | descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", aliquota_iva=22) 191 | o.dati_beni_servizi.add_dettaglio_linee( 192 | descrizione="Line 3", quantita="3.5", unita_misura="A", prezzo_unitario="0.5", aliquota_iva=10) 193 | o.dati_beni_servizi.build_dati_riepilogo() 194 | o.build_importo_totale_documento() 195 | 196 | # It would be 19.49 if all operations where made in full float, but 197 | # since we first compute imponibile and imposta, fit them into the 198 | # required number of digits, then add them up, 19.50 is what we get 199 | self.assertEqual(o.dati_generali.dati_generali_documento.importo_totale_documento, Decimal("19.50")) 200 | 201 | def test_rounding_xml(self): 202 | f = a38.FatturaPrivati12() 203 | o = f.fattura_elettronica_body[0] 204 | o.dati_beni_servizi.add_dettaglio_linee( 205 | descrizione="Line 1", prezzo_unitario="0.35", aliquota_iva=10, unita_misura='pz', quantita=1) 206 | o.dati_beni_servizi.build_dati_riepilogo() 207 | o.build_importo_totale_documento() 208 | 209 | self.assertEqual( 210 | f.fattura_elettronica_body[0].dati_generali.dati_generali_documento.importo_totale_documento, 211 | Decimal("0.39")) 212 | self.assertEqual(f.build_etree().getroot().find('.//ImportoTotaleDocumento').text, "0.39") 213 | 214 | 215 | class TestFatturaPrivati12(TestFatturaMixin, TestCase): 216 | def build_sample(self): 217 | cedente_prestatore = a38.CedentePrestatore( 218 | a38.DatiAnagraficiCedentePrestatore( 219 | a38.IdFiscaleIVA("IT", "01234567890"), 220 | codice_fiscale="NTNBLN22C23A123U", 221 | anagrafica=a38.Anagrafica(denominazione="Test User"), 222 | regime_fiscale="RF01", 223 | ), 224 | a38.Sede(indirizzo="via Monferrato", numero_civico="1", 225 | cap="50100", comune="Firenze", provincia="FI", nazione="IT"), 226 | iscrizione_rea=a38.IscrizioneREA( 227 | ufficio="FI", 228 | numero_rea="123456", 229 | stato_liquidazione="LN", 230 | ), 231 | contatti=a38.Contatti(email="local_part@pec_domain.it"), 232 | ) 233 | 234 | cessionario_committente = a38.CessionarioCommittente( 235 | a38.DatiAnagraficiCessionarioCommittente( 236 | a38.IdFiscaleIVA("IT", "76543210987"), 237 | anagrafica=a38.Anagrafica(denominazione="A Company SRL"), 238 | ), 239 | a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", 240 | comune="Firenze", provincia="FI", nazione="IT"), 241 | ) 242 | 243 | f = a38.FatturaPrivati12() 244 | f.fattura_elettronica_header.dati_trasmissione.update( 245 | a38.IdTrasmittente("IT", "10293847561"), 246 | codice_destinatario="FUFUFUF", 247 | ) 248 | f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore 249 | f.fattura_elettronica_header.cessionario_committente = cessionario_committente 250 | 251 | body = f.fattura_elettronica_body[0] 252 | body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento( 253 | tipo_documento="TD01", 254 | divisa="EUR", 255 | data=datetime.date(2019, 1, 1), 256 | numero=1, 257 | causale=["Test billing"], 258 | ) 259 | 260 | body.dati_beni_servizi.add_dettaglio_linee( 261 | descrizione="Test item", quantita=2, unita_misura="kg", 262 | prezzo_unitario="25.50", aliquota_iva="22.00") 263 | 264 | body.dati_beni_servizi.add_dettaglio_linee( 265 | descrizione="Other item", quantita=1, unita_misura="kg", 266 | prezzo_unitario="15.50", aliquota_iva="22.00") 267 | 268 | body.dati_beni_servizi.build_dati_riepilogo() 269 | body.build_importo_totale_documento() 270 | 271 | f.fattura_elettronica_body.append(body) 272 | 273 | return f 274 | 275 | def test_initial_body_exists(self): 276 | f = a38.FatturaPrivati12() 277 | self.assertEqual(len(f.fattura_elettronica_body), 1) 278 | self.assertFalse(f.fattura_elettronica_body[0].has_value()) 279 | 280 | def test_validate(self): 281 | f = self.build_sample() 282 | self.assertEqual(f.fattura_elettronica_header.dati_trasmissione.formato_trasmissione, "FPR12") 283 | self.assert_validates(f) 284 | 285 | def test_serialize(self): 286 | f = self.build_sample() 287 | self.assertEqual(f.fattura_elettronica_header.dati_trasmissione.formato_trasmissione, "FPR12") 288 | tree = f.build_etree() 289 | with io.StringIO() as out: 290 | tree.write(out, encoding="unicode") 291 | xml = out.getvalue() 292 | 293 | self.assertIn( 294 | '', xml) 296 | self.assertIn('FPR12', xml) 297 | 298 | def test_serialize_lxml(self): 299 | from a38 import builder 300 | if not builder.HAVE_LXML: 301 | raise SkipTest("lxml is not available") 302 | 303 | f = self.build_sample() 304 | tree = f.build_etree(lxml=True) 305 | with io.BytesIO() as out: 306 | tree.write(out) 307 | xml = out.getvalue() 308 | 309 | self.assertIn( 310 | b'', xml) 312 | self.assertIn(b'FPR12', xml) 313 | 314 | def test_to_python(self): 315 | f = self.build_sample() 316 | py = f.to_python(namespace="a38") 317 | parsed = eval(py) 318 | self.assertEqual(f, parsed) 319 | 320 | def test_parse(self): 321 | f = self.build_sample() 322 | tree = f.build_etree() 323 | with io.StringIO() as out: 324 | tree.write(out, encoding="unicode") 325 | xml1 = out.getvalue() 326 | 327 | f = a38.FatturaPrivati12() 328 | f.from_etree(tree.getroot()) 329 | self.assert_validates(f) 330 | tree = f.build_etree() 331 | with io.StringIO() as out: 332 | tree.write(out, encoding="unicode") 333 | xml2 = out.getvalue() 334 | 335 | self.assertEqual(xml1, xml2) 336 | 337 | f = a38.auto_from_etree(tree.getroot()) 338 | self.assert_validates(f) 339 | tree = f.build_etree() 340 | with io.StringIO() as out: 341 | tree.write(out, encoding="unicode") 342 | xml2 = out.getvalue() 343 | 344 | 345 | class TestSamples(TestFatturaMixin, TestCase): 346 | @classmethod 347 | def setUpClass(cls): 348 | super().setUpClass() 349 | cls.codecs = [ 350 | # No p7m because it cannot write 351 | codec.XML(), 352 | codec.JSON(), 353 | codec.YAML(), 354 | codec.Python(loadable=True), 355 | ] 356 | 357 | def assertCodecsCanCope(self, f): 358 | for cod in self.codecs: 359 | with self.subTest(codec=cod.__class__.__name__): 360 | with tempfile.NamedTemporaryFile() as tf: 361 | cod.save(f, tf.name) 362 | f1 = cod.load(tf.name) 363 | self.assertEqual(f, f1) 364 | 365 | def test_parse_dati_trasporto(self): 366 | import xml.etree.ElementTree as ET 367 | tree = ET.parse("tests/data/dati_trasporto.xml") 368 | f = a38.auto_from_etree(tree.getroot()) 369 | self.assertCodecsCanCope(f) 370 | 371 | def test_parse_unicode(self): 372 | import xml.etree.ElementTree as ET 373 | tree = ET.parse("tests/data/unicode.xml") 374 | f = a38.auto_from_etree(tree.getroot()) 375 | self.assertCodecsCanCope(f) 376 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | from decimal import Decimal 4 | from unittest import TestCase 5 | 6 | from a38 import fields, models, validation 7 | from a38.builder import Builder 8 | from a38.diff import Diff 9 | 10 | NS = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2" 11 | 12 | 13 | class FieldTestMixin: 14 | field_class = fields.Field 15 | 16 | def get_field(self, *args, **kw): 17 | f = self.field_class(*args, **kw) 18 | f.set_name("sample") 19 | return f 20 | 21 | def test_xmltag(self): 22 | # XML tag is autogenerated from the field name 23 | f = self.get_field() 24 | self.assertEqual(f.get_xmltag(), "Sample") 25 | 26 | # But can be overridden with the xmltag argument 27 | f = self.get_field(xmltag="OtherName") 28 | self.assertEqual(f.get_xmltag(), "OtherName") 29 | 30 | def test_empty(self): 31 | f = self.get_field() 32 | 33 | # Validating a field with null=False raises an error 34 | self.assert_validates(f, None, result=None, errors=[ 35 | "sample: missing value", 36 | ]) 37 | 38 | # But null values are tolerated outside validation, while structures 39 | # are being filled 40 | self.assertIsNone(f.clean_value(None)) 41 | 42 | # Values set to None are skipped in XML 43 | self.assertIsNone(self.to_xml(f, None)) 44 | 45 | def test_nullable(self): 46 | f = self.get_field(null=True) 47 | self.assert_validates(f, None, result=None) 48 | self.assertIsNone(f.clean_value(None)) 49 | 50 | def test_construct_default(self): 51 | f = self.get_field() 52 | self.assertIsNone(f.get_construct_default()) 53 | 54 | def test_value(self): 55 | f = self.get_field() 56 | self.assert_validates(f, "value", result="value") 57 | 58 | def test_default(self): 59 | f = self.get_field(default="default") 60 | self.assertEqual(f.clean_value(None), "default") 61 | self.assertEqual(self.to_xml(f, None), "default") 62 | 63 | def test_xml(self): 64 | f = self.get_field(null=True) 65 | self.assertEqual(self.to_xml(f, "value"), "value") 66 | 67 | def test_to_python_none(self): 68 | f = self.get_field() 69 | self.assert_to_python_works(f, None) 70 | 71 | def test_diff_none(self): 72 | f = self.get_field() 73 | self.assert_diff_empty(f, None, None) 74 | 75 | def assert_validates(self, field, value, result, warnings=[], errors=[]): 76 | val = validation.Validation() 77 | validated = field.validate(val, value) 78 | self.assertEqual([str(x) for x in val.warnings], warnings) 79 | self.assertEqual([str(x) for x in val.errors], errors) 80 | self.assertEqual(validated, result) 81 | 82 | def assert_to_python_works(self, field, value, **kw): 83 | kw.setdefault("namespace", False) 84 | py = field.to_python(value, **kw) 85 | try: 86 | parsed = eval(py) 87 | except Exception as e: 88 | self.fail("cannot parse generated python {}: {}".format(repr(py), str(e))) 89 | clean = field.clean_value(parsed) 90 | self.assertEqual(clean, field.clean_value(value)) 91 | 92 | def assert_diff_empty(self, field, first, second): 93 | """ 94 | Check that the field diff between the two values is empty 95 | """ 96 | res = Diff() 97 | field.diff(res, first, second) 98 | if res.differences: 99 | self.assertEqual([(d.prefix, d.field, d.first, d.second) for d in res.differences], []) 100 | 101 | def assert_diff(self, field, first, second, expected): 102 | """ 103 | Check that the field diff between the two differing values, is as 104 | expected 105 | """ 106 | res = Diff() 107 | field.diff(res, first, second) 108 | self.assertEqual([str(d) for d in res.differences], expected) 109 | 110 | def assert_field_diff(self, field, first, second): 111 | """ 112 | Check that the field diff, from a non-composite field, between the two 113 | differing values, is as expected 114 | """ 115 | self.assert_diff(field, first, None, ["sample: second is not set"]) 116 | self.assert_diff(field, None, first, ["sample: first is not set"]) 117 | self.assert_diff(field, second, None, ["sample: second is not set"]) 118 | self.assert_diff(field, None, second, ["sample: first is not set"]) 119 | self.assert_diff(field, first, second, ["sample: first: {}, second: {}".format( 120 | field.to_str(field.clean_value(first)), 121 | field.to_str(field.clean_value(second)))]) 122 | 123 | def to_xml(self, field, value): 124 | """ 125 | Serialize the field to XML. Returns None is the field generated no 126 | value in the XML. 127 | """ 128 | builder = Builder() 129 | with builder.element("T"): 130 | field.to_xml(builder, value) 131 | tree = builder.get_tree() 132 | root = tree.getroot() 133 | if not list(root): 134 | return None 135 | with io.StringIO() as out: 136 | tree.write(out, encoding="unicode") 137 | return out.getvalue() 138 | 139 | def mkdt(self, ye, mo, da, ho, mi, se=0, tz=None): 140 | if tz is None: 141 | tz = fields.DateTimeField.tz_rome 142 | return tz.localize(datetime.datetime(ye, mo, da, ho, mi, se)) 143 | 144 | 145 | class TestField(FieldTestMixin, TestCase): 146 | pass 147 | 148 | 149 | class TestStringField(FieldTestMixin, TestCase): 150 | field_class = fields.StringField 151 | 152 | def test_value(self): 153 | f = self.get_field() 154 | self.assert_validates(f, "value", result="value") 155 | self.assert_validates(f, 12, result="12") 156 | 157 | def test_default(self): 158 | f = self.get_field(default="default") 159 | self.assertEqual(f.clean_value(None), "default") 160 | self.assertEqual(self.to_xml(f, None), "default") 161 | 162 | def test_length(self): 163 | f = self.get_field(length=3) 164 | self.assert_validates(f, "va", result="va", errors=[ 165 | "sample: 'va' should be at least 3 characters long", 166 | ]) 167 | self.assert_validates(f, "valu", result="valu", errors=[ 168 | "sample: 'valu' should be no more than 3 characters long", 169 | ]) 170 | self.assert_validates(f, 1.15, result="1.15", errors=[ 171 | "sample: '1.15' should be no more than 3 characters long", 172 | ]) 173 | self.assert_validates(f, "val", result="val") 174 | self.assert_validates(f, 1.2, result="1.2") 175 | 176 | def test_min_length(self): 177 | f = self.get_field(min_length=3) 178 | self.assert_validates(f, "va", result="va", errors=[ 179 | "sample: 'va' should be at least 3 characters long", 180 | ]) 181 | self.assert_validates(f, "valu", result="valu") 182 | self.assert_validates(f, "val", result="val") 183 | self.assert_validates(f, 1.2, result="1.2") 184 | self.assert_validates(f, 1.15, result="1.15") 185 | 186 | def test_max_length(self): 187 | f = self.get_field(max_length=3) 188 | self.assert_validates(f, "v", result="v") 189 | self.assert_validates(f, "va", result="va") 190 | self.assert_validates(f, "val", result="val") 191 | self.assert_validates(f, "valu", result="valu", errors=[ 192 | "sample: 'valu' should be no more than 3 characters long", 193 | ]) 194 | 195 | def test_choices(self): 196 | f = self.get_field(choices=("A", "B")) 197 | self.assert_validates(f, "A", result="A") 198 | self.assert_validates(f, "B", result="B") 199 | self.assert_validates(f, "C", result="C", errors=[ 200 | "sample: 'C' is not a valid choice for this field", 201 | ]) 202 | self.assert_validates(f, "a", result="a", errors=[ 203 | "sample: 'a' is not a valid choice for this field", 204 | ]) 205 | self.assert_validates(f, None, result=None, errors=[ 206 | "sample: missing value", 207 | ]) 208 | 209 | def test_choices_nullable(self): 210 | f = self.get_field(choices=("A", "B"), null=True) 211 | self.assert_validates(f, "A", result="A") 212 | self.assert_validates(f, "B", result="B") 213 | self.assert_validates(f, None, result=None) 214 | self.assert_validates(f, "C", result="C", errors=[ 215 | "sample: 'C' is not a valid choice for this field", 216 | ]) 217 | self.assert_validates(f, "a", result="a", errors=[ 218 | "sample: 'a' is not a valid choice for this field", 219 | ]) 220 | 221 | def test_to_python(self): 222 | f = self.get_field() 223 | self.assert_to_python_works(f, "") 224 | self.assert_to_python_works(f, "foo") 225 | self.assert_to_python_works(f, "'\"\n") 226 | self.assert_to_python_works(f, r"\d\t\n") 227 | 228 | def test_diff(self): 229 | f = self.get_field() 230 | self.assert_diff_empty(f, "", "") 231 | self.assert_diff_empty(f, "a", "a") 232 | self.assert_field_diff(f, "a", "b") 233 | 234 | def test_xml(self): 235 | f = self.get_field(null=True) 236 | self.assertEqual(self.to_xml(f, "value"), "value") 237 | 238 | 239 | class TestIntegerField(FieldTestMixin, TestCase): 240 | field_class = fields.IntegerField 241 | 242 | def test_value(self): 243 | f = self.get_field() 244 | self.assert_validates(f, 12, result=12) 245 | self.assert_validates(f, "12", result=12) 246 | self.assert_validates(f, 12.3, result=12) 247 | self.assert_validates(f, "foo", result="foo", errors=[ 248 | "sample: invalid literal for int() with base 10: 'foo'", 249 | ]) 250 | 251 | def test_default(self): 252 | f = self.get_field(default=7) 253 | self.assertEqual(f.clean_value(None), 7) 254 | self.assertEqual(self.to_xml(f, None), "7") 255 | 256 | def test_max_length(self): 257 | f = self.get_field(max_length=3) 258 | self.assert_validates(f, 1, result=1) 259 | self.assert_validates(f, 12, result=12) 260 | self.assert_validates(f, 123, result=123) 261 | self.assert_validates(f, 1234, result=1234, errors=[ 262 | "sample: '1234' should be no more than 3 digits long", 263 | ]) 264 | 265 | def test_choices(self): 266 | f = self.get_field(choices=(1, 2)) 267 | self.assert_validates(f, 1, result=1) 268 | self.assert_validates(f, 2, result=2) 269 | self.assert_validates(f, 3, result=3, errors=[ 270 | "sample: 3 is not a valid choice for this field", 271 | ]) 272 | self.assert_validates(f, None, result=None, errors=[ 273 | "sample: missing value", 274 | ]) 275 | 276 | def test_choices_nullable(self): 277 | f = self.get_field(choices=(1, 2), null=True) 278 | self.assert_validates(f, 1, result=1) 279 | self.assert_validates(f, 2, result=2) 280 | self.assert_validates(f, 3, result=3, errors=[ 281 | "sample: 3 is not a valid choice for this field", 282 | ]) 283 | self.assert_validates(f, None, result=None) 284 | 285 | def test_to_python(self): 286 | f = self.get_field() 287 | self.assert_to_python_works(f, 1) 288 | self.assert_to_python_works(f, 123456) 289 | self.assert_to_python_works(f, 3 ** 80) 290 | 291 | def test_diff(self): 292 | f = self.get_field() 293 | self.assert_diff_empty(f, 1, "1") 294 | self.assert_field_diff(f, 1, "2") 295 | 296 | def test_xml(self): 297 | f = self.get_field(null=True) 298 | self.assertEqual(self.to_xml(f, 1), "1") 299 | 300 | 301 | class TestDecimalField(FieldTestMixin, TestCase): 302 | field_class = fields.DecimalField 303 | 304 | def test_value(self): 305 | f = self.get_field() 306 | self.assert_validates(f, 12, result=Decimal("12.00")) 307 | self.assert_validates(f, "12", result=Decimal("12.00")) 308 | self.assert_validates(f, "12.345", result=Decimal("12.35")) 309 | self.assert_validates(f, "foo", result="foo", errors=[ 310 | "sample: 'foo' cannot be converted to Decimal", 311 | ]) 312 | 313 | def test_decimals_fixed(self): 314 | f = self.get_field(decimals=3) 315 | self.assert_validates(f, 12, result=Decimal("12.000")) 316 | self.assert_validates(f, "12", result=Decimal("12.000")) 317 | self.assert_validates(f, "12.345", result=Decimal("12.345")) 318 | self.assert_validates(f, "12.345678", result=Decimal("12.346")) 319 | self.assert_validates(f, "foo", result="foo", errors=[ 320 | "sample: 'foo' cannot be converted to Decimal", 321 | ]) 322 | 323 | def test_decimals_range(self): 324 | f = self.get_field(decimals=(2, 6)) 325 | self.assert_validates(f, 12, result=Decimal("12.00")) 326 | self.assert_validates(f, "12", result=Decimal("12.00")) 327 | self.assert_validates(f, "12.345", result=Decimal("12.345")) 328 | self.assert_validates(f, "12.345678", result=Decimal("12.345678")) 329 | self.assert_validates(f, "12.3456789", result=Decimal("12.345679")) 330 | self.assert_validates(f, "foo", result="foo", errors=[ 331 | "sample: 'foo' cannot be converted to Decimal", 332 | ]) 333 | 334 | def test_default(self): 335 | f = self.get_field(default="7.0") 336 | self.assertEqual(f.clean_value(None), Decimal("7.0")) 337 | self.assertEqual(self.to_xml(f, None), "7.00") 338 | 339 | def test_max_length(self): 340 | f = self.get_field(max_length=4) 341 | self.assert_validates(f, 1, result=Decimal("1.00")) 342 | # 12 becomes 12.00 which is 5 characters long on a max_length of 4 343 | self.assert_validates(f, 12, result=Decimal("12.00"), errors=[ 344 | "sample: '12.00' should be no more than 4 digits long", 345 | ]) 346 | 347 | def test_choices(self): 348 | f = self.get_field(choices=("1.1", "2.2"), decimals=1) 349 | self.assert_validates(f, "1.1", result=Decimal("1.1")) 350 | self.assert_validates(f, Decimal("2.2"), result=Decimal("2.2")) 351 | # 1.1 does not have an exact decimal representation, but clean_value 352 | # will constrain it to the configured number of digits 353 | self.assert_validates(f, 1.1, result=Decimal("1.1")) 354 | self.assert_validates(f, None, result=None, errors=[ 355 | "sample: missing value", 356 | ]) 357 | 358 | def test_choices_nullable(self): 359 | f = self.get_field(choices=("1.1", "2.2"), null=True, decimals=1) 360 | self.assert_validates(f, "1.1", result=Decimal("1.1")) 361 | self.assert_validates(f, Decimal("2.2"), result=Decimal("2.2")) 362 | self.assert_validates(f, None, result=None) 363 | # 1.1 does not have an exact decimal representation, but gets fit into 364 | # the allowed decimals 365 | self.assert_validates(f, 1.1, result=Decimal("1.1")) 366 | 367 | def test_to_python(self): 368 | f = self.get_field() 369 | self.assert_to_python_works(f, "1.2") 370 | self.assert_to_python_works(f, Decimal("1.20")) 371 | 372 | def test_diff(self): 373 | f = self.get_field() 374 | self.assert_diff_empty(f, Decimal("1.0"), "1.0") 375 | self.assert_diff_empty(f, "1.0001", "1.0002") 376 | self.assert_field_diff(f, "1.1", "1.2") 377 | 378 | def test_xml(self): 379 | f = self.get_field(null=True) 380 | self.assertEqual(self.to_xml(f, "12.345"), "12.35") 381 | self.assertEqual(self.to_xml(f, "34.567"), "34.57") 382 | 383 | 384 | class TestDateField(FieldTestMixin, TestCase): 385 | field_class = fields.DateField 386 | 387 | def test_value(self): 388 | f = self.get_field() 389 | self.assert_validates(f, datetime.date(2019, 1, 2), result=datetime.date(2019, 1, 2)) 390 | self.assert_validates(f, "2019-01-02", result=datetime.date(2019, 1, 2)) 391 | self.assert_validates(f, datetime.datetime(2019, 1, 2, 12, 30), result=datetime.date(2019, 1, 2)) 392 | self.assert_validates(f, "foo", result="foo", errors=[ 393 | "sample: Date 'foo' does not begin with YYYY-mm-dd", 394 | ]) 395 | self.assert_validates(f, [123], result=[123], errors=[ 396 | "sample: '[123]' is not an instance of str, datetime.date or datetime.datetime", 397 | ]) 398 | 399 | def test_default(self): 400 | f = self.get_field(default="2019-01-02") 401 | self.assertEqual(f.clean_value(None), datetime.date(2019, 1, 2)) 402 | self.assertEqual(self.to_xml(f, None), "2019-01-02") 403 | 404 | def test_choices(self): 405 | f = self.get_field(choices=("2019-01-01", "2019-01-02")) 406 | self.assert_validates(f, "2019-01-01", result=datetime.date(2019, 1, 1)) 407 | self.assert_validates(f, "2019-01-02", result=datetime.date(2019, 1, 2)) 408 | self.assert_validates(f, "2019-01-03", result=datetime.date(2019, 1, 3), errors=[ 409 | "sample: datetime.date(2019, 1, 3) is not a valid choice for this field", 410 | ]) 411 | self.assert_validates(f, None, result=None, errors=[ 412 | "sample: missing value", 413 | ]) 414 | 415 | def test_choices_nullable(self): 416 | f = self.get_field(choices=("2019-01-01", "2019-01-02"), null=True) 417 | self.assert_validates(f, "2019-01-01", result=datetime.date(2019, 1, 1)) 418 | self.assert_validates(f, "2019-01-02", result=datetime.date(2019, 1, 2)) 419 | self.assert_validates(f, "2019-01-03", result=datetime.date(2019, 1, 3), errors=[ 420 | "sample: datetime.date(2019, 1, 3) is not a valid choice for this field", 421 | ]) 422 | self.assert_validates(f, None, result=None) 423 | 424 | def test_to_python(self): 425 | f = self.get_field() 426 | self.assert_to_python_works(f, "2019-01-03") 427 | self.assert_to_python_works(f, datetime.date(2019, 2, 4)) 428 | 429 | def test_diff(self): 430 | f = self.get_field() 431 | self.assert_diff_empty(f, datetime.date(2019, 1, 1), "2019-01-01") 432 | self.assert_field_diff(f, datetime.date(2019, 1, 1), "2019-01-02") 433 | self.assert_field_diff(f, "2019-01-01", "2019-01-02") 434 | 435 | def test_xml(self): 436 | f = self.get_field(null=True) 437 | self.assertEqual(self.to_xml(f, datetime.date(2019, 1, 2)), "2019-01-02") 438 | self.assertEqual(self.to_xml(f, "2019-01-02"), "2019-01-02") 439 | 440 | 441 | class TestDateTimeField(FieldTestMixin, TestCase): 442 | field_class = fields.DateTimeField 443 | 444 | def test_value(self): 445 | f = self.get_field() 446 | self.assert_validates(f, datetime.datetime(2019, 1, 2, 12, 30), result=self.mkdt(2019, 1, 2, 12, 30)) 447 | self.assert_validates(f, "2019-01-02T12:30:00", result=self.mkdt(2019, 1, 2, 12, 30)) 448 | self.assert_validates(f, datetime.datetime(2019, 1, 2, 12, 30), result=self.mkdt(2019, 1, 2, 12, 30)) 449 | self.assert_validates(f, "foo", result="foo", errors=[ 450 | "sample: ISO string too short", 451 | ]) 452 | self.assert_validates(f, [123], result=[123], errors=[ 453 | "sample: '[123]' is not an instance of str, datetime.date or datetime.datetime", 454 | ]) 455 | 456 | def test_default(self): 457 | f = self.get_field(default="2019-01-02T12:30:00") 458 | self.assertEqual(f.clean_value(None), self.mkdt(2019, 1, 2, 12, 30)) 459 | self.assertEqual(self.to_xml(f, None), "2019-01-02T12:30:00+01:00") 460 | 461 | def test_choices(self): 462 | f = self.get_field(choices=("2019-01-01T12:00:00", "2019-01-02T12:30:00")) 463 | self.assert_validates(f, "2019-01-01T12:00:00", result=self.mkdt(2019, 1, 1, 12, 00)) 464 | self.assert_validates(f, "2019-01-02T12:30:00", result=self.mkdt(2019, 1, 2, 12, 30)) 465 | self.assert_validates(f, self.mkdt(2019, 1, 2, 12, 15), result=self.mkdt(2019, 1, 2, 12, 15), errors=[ 466 | "sample: 2019-01-02T12:15:00+01:00 is not a valid choice for this field", 467 | ]) 468 | self.assert_validates(f, None, result=None, errors=[ 469 | "sample: missing value", 470 | ]) 471 | 472 | def test_choices_nullable(self): 473 | f = self.get_field(choices=("2019-01-01T12:00:00", "2019-01-02T12:30:00"), null=True) 474 | self.assert_validates(f, "2019-01-01T12:00:00", result=self.mkdt(2019, 1, 1, 12, 00)) 475 | self.assert_validates(f, "2019-01-02T12:30:00", result=self.mkdt(2019, 1, 2, 12, 30)) 476 | self.assert_validates(f, None, result=None) 477 | self.assert_validates(f, self.mkdt(2019, 1, 2, 12, 15), result=self.mkdt(2019, 1, 2, 12, 15), errors=[ 478 | "sample: 2019-01-02T12:15:00+01:00 is not a valid choice for this field", 479 | ]) 480 | 481 | def test_to_python(self): 482 | f = self.get_field() 483 | self.assert_to_python_works(f, "2019-01-03T04:05:06") 484 | self.assert_to_python_works(f, self.mkdt(2019, 1, 2, 3, 4, 5)) 485 | 486 | def test_diff(self): 487 | f = self.get_field() 488 | self.assert_diff_empty(f, self.mkdt(2019, 1, 2, 3, 4, 5), "2019-01-02T03:04:05+01:00") 489 | self.assert_field_diff(f, self.mkdt(2019, 1, 2, 3, 4, 5), self.mkdt(2019, 1, 2, 3, 4, 6)) 490 | self.assert_field_diff(f, self.mkdt(2019, 1, 2, 3, 4, 5), "2019-01-02T03:04:05+02:00") 491 | 492 | def test_xml(self): 493 | f = self.get_field(null=True) 494 | self.assertEqual( 495 | self.to_xml(f, self.mkdt(2019, 1, 2, 12, 30)), 496 | "2019-01-02T12:30:00+01:00") 497 | self.assertEqual(self.to_xml(f, "2019-01-02T12:13:14"), "2019-01-02T12:13:14+01:00") 498 | 499 | 500 | class TestProgressivoInvioField(FieldTestMixin, TestCase): 501 | field_class = fields.ProgressivoInvioField 502 | 503 | def test_construct_default(self): 504 | f = self.get_field() 505 | 506 | # The field generates always different, always increasing values 507 | a = f.get_construct_default() 508 | b = f.get_construct_default() 509 | c = f.get_construct_default() 510 | d = f.get_construct_default() 511 | self.assertNotEqual(a, b) 512 | self.assertNotEqual(a, c) 513 | self.assertNotEqual(a, d) 514 | self.assertNotEqual(b, c) 515 | self.assertNotEqual(b, d) 516 | self.assertNotEqual(c, d) 517 | self.assertLess(a, b) 518 | self.assertLess(b, c) 519 | self.assertLess(c, d) 520 | 521 | def test_to_python(self): 522 | f = self.get_field() 523 | self.assert_to_python_works(f, "BFABFAF") 524 | 525 | 526 | class Sample(models.Model): 527 | name = fields.StringField() 528 | value = fields.IntegerField() 529 | 530 | 531 | class TestModelField(FieldTestMixin, TestCase): 532 | field_class = fields.ModelField 533 | 534 | def get_field(self, *args, **kw): 535 | return super().get_field(Sample, *args, **kw) 536 | 537 | def test_construct_default(self): 538 | f = self.get_field() 539 | value = f.get_construct_default() 540 | self.assertIsInstance(value, Sample) 541 | self.assertIsNone(value.name) 542 | self.assertIsNone(value.value) 543 | 544 | def test_empty(self): 545 | super().test_empty() 546 | 547 | # Empty models are skipped in XML 548 | f = self.get_field() 549 | self.assertIsNone(self.to_xml(f, Sample())) 550 | 551 | def test_value(self): 552 | f = self.get_field() 553 | self.assert_validates(f, Sample("test", 7), result=Sample("test", 7)) 554 | 555 | def test_default(self): 556 | f = self.get_field(default=Sample("test", 7)) 557 | self.assertEqual(f.clean_value(None), Sample("test", 7)) 558 | self.assertEqual(self.to_xml(f, None), "test7") 559 | 560 | def test_to_python(self): 561 | f = self.get_field() 562 | self.assert_to_python_works(f, Sample("test", 7)) 563 | 564 | def test_diff(self): 565 | f = self.get_field() 566 | self.assert_diff_empty(f, Sample("test", 7), Sample("test", "7")) 567 | self.assert_diff(f, Sample("test", 6), None, [ 568 | "sample: second is not set", 569 | ]) 570 | self.assert_diff(f, Sample("test", 6), Sample("test", 7), [ 571 | "sample.value: first: 6, second: 7", 572 | ]) 573 | self.assert_diff(f, Sample("test1", 6), Sample("test2", 7), [ 574 | "sample.name: first: test1, second: test2", 575 | "sample.value: first: 6, second: 7", 576 | ]) 577 | 578 | def test_xml(self): 579 | f = self.get_field(null=True) 580 | self.assertEqual(self.to_xml(f, Sample("test", 7)), "test7") 581 | 582 | 583 | class TestModelListField(FieldTestMixin, TestCase): 584 | field_class = fields.ModelListField 585 | 586 | def get_field(self, *args, **kw): 587 | return super().get_field(Sample, *args, **kw) 588 | 589 | def test_construct_default(self): 590 | f = self.get_field() 591 | value = f.get_construct_default() 592 | self.assertEqual(value, []) 593 | 594 | def test_value(self): 595 | f = self.get_field() 596 | self.assert_validates(f, [], result=[], errors=[ 597 | "sample: missing value", 598 | ]) 599 | self.assert_validates(f, [Sample("test", 7)], result=[Sample("test", 7)]) 600 | 601 | f = self.get_field(null=True) 602 | self.assert_validates(f, [], result=[]) 603 | 604 | def test_min_num(self): 605 | f = self.get_field(min_num=2) 606 | self.assertEqual(f.get_construct_default(), [Sample(), Sample()]) 607 | self.assertEqual(f.clean_value([Sample(), Sample(), Sample()]), [Sample(), Sample()]) 608 | 609 | self.assert_validates(f, [Sample("test", 7)], result=[Sample("test", 7)], errors=[ 610 | "sample: list must have at least 2 elements, but has only 1", 611 | ]) 612 | self.assert_validates(f, [Sample("test", 6), Sample("test", 7)], result=[Sample("test", 6), Sample("test", 7)]) 613 | 614 | def test_default(self): 615 | f = self.get_field(default=[Sample("test", 7)]) 616 | self.assertEqual(f.clean_value(None), [Sample("test", 7)]) 617 | self.assertEqual(self.to_xml(f, None), "test7") 618 | 619 | def test_to_python(self): 620 | f = self.get_field() 621 | self.assert_to_python_works(f, [Sample("test", 7)]) 622 | 623 | def test_diff(self): 624 | f = self.get_field() 625 | self.assert_diff_empty(f, [], None) 626 | self.assert_diff_empty(f, [Sample("test", 7)], [Sample("test", "7")]) 627 | self.assert_diff_empty(f, [Sample("test", 6)], [Sample("test", 6), None]) 628 | self.assert_diff(f, [Sample("test", 6)], None, [ 629 | "sample: second is not set", 630 | ]) 631 | self.assert_diff(f, [Sample("test", 6)], [], [ 632 | "sample: second is not set", 633 | ]) 634 | self.assert_diff(f, [Sample("test", 6)], [Sample("test", 7)], [ 635 | "sample.0.value: first: 6, second: 7", 636 | ]) 637 | self.assert_diff(f, [Sample("test", 6)], [Sample("test", 6), Sample("test", 7)], [ 638 | "sample: second has 1 extra element", 639 | ]) 640 | self.assert_diff(f, [Sample("test", 6)], [Sample("test", 5), Sample("test", 7)], [ 641 | "sample.0.value: first: 6, second: 5", 642 | "sample: second has 1 extra element", 643 | ]) 644 | 645 | def test_xml(self): 646 | f = self.get_field(null=True) 647 | self.assertIsNone(self.to_xml(f, [])) 648 | self.assertEqual( 649 | self.to_xml(f, [Sample("test", 7)]), 650 | "test7") 651 | 652 | 653 | class TestListField(FieldTestMixin, TestCase): 654 | field_class = fields.ListField 655 | 656 | def get_field(self, *args, **kw): 657 | return super().get_field(fields.StringField(), *args, **kw) 658 | 659 | def test_construct_default(self): 660 | f = self.get_field() 661 | value = f.get_construct_default() 662 | self.assertEqual(value, []) 663 | 664 | def test_value(self): 665 | f = self.get_field() 666 | self.assert_validates(f, [], result=[], errors=[ 667 | "sample: missing value", 668 | ]) 669 | self.assert_validates(f, ["test1", "test2"], result=["test1", "test2"]) 670 | 671 | f = self.get_field(null=True) 672 | self.assert_validates(f, [], result=[]) 673 | 674 | def test_min_num(self): 675 | f = self.get_field(min_num=2) 676 | self.assertEqual(f.get_construct_default(), [None, None]) 677 | self.assertEqual(f.clean_value([None, None, None]), [None, None]) 678 | 679 | self.assert_validates(f, ["test1"], result=["test1"], errors=[ 680 | "sample: list must have at least 2 elements, but has only 1", 681 | ]) 682 | self.assert_validates(f, ["test1", "test2"], result=["test1", "test2"]) 683 | 684 | def test_default(self): 685 | f = self.get_field(default=["test1", "test2"]) 686 | self.assertEqual(f.clean_value(None), ["test1", "test2"]) 687 | self.assertEqual(self.to_xml(f, None), "test1test2") 688 | 689 | def test_to_python(self): 690 | f = self.get_field() 691 | self.assert_to_python_works(f, ["test1", "foo"]) 692 | 693 | f = super().get_field(fields.DateTimeField()) 694 | self.assert_to_python_works(f, [self.mkdt(2019, 1, 2, 3, 4), self.mkdt(2019, 2, 3, 4, 5)]) 695 | 696 | def test_diff(self): 697 | f = self.get_field() 698 | self.assert_diff_empty(f, [], None) 699 | self.assert_diff_empty(f, ["test"], ["test"]) 700 | self.assert_diff_empty(f, ["test"], ["test", None]) 701 | self.assert_diff(f, ["test"], ["test1"], [ 702 | "sample.0: first: test, second: test1", 703 | ]) 704 | self.assert_diff(f, ["test"], ["test", "test"], [ 705 | "sample: second has 1 extra element", 706 | ]) 707 | self.assert_diff(f, ["test"], ["test1", "test2"], [ 708 | "sample.0: first: test, second: test1", 709 | "sample: second has 1 extra element", 710 | ]) 711 | 712 | def test_xml(self): 713 | f = self.get_field(null=True) 714 | self.assertIsNone(self.to_xml(f, [])) 715 | self.assertEqual(self.to_xml(f, ["test", "foo"]), "testfoo") 716 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from a38 import fields, models 4 | 5 | 6 | class Sample(models.Model): 7 | name = fields.StringField() 8 | value = fields.IntegerField() 9 | 10 | 11 | class Sample1(models.Model): 12 | name = fields.StringField() 13 | type = fields.StringField(choices=("A", "B")) 14 | 15 | 16 | class TestModel(TestCase): 17 | def test_assignment(self): 18 | o = Sample() 19 | # Values are cleaned on assignment 20 | o.name = 12 21 | o.value = "42" 22 | self.assertEqual(o.name, "12") 23 | self.assertEqual(o.value, 42) 24 | 25 | def test_misspelled_field(self): 26 | o = Sample() 27 | with self.assertRaises(AttributeError): 28 | o.nome = "foo" 29 | 30 | def test_clean_value(self): 31 | # Assign from a model 32 | val = Sample.clean_value(Sample1("foo", "A")) 33 | self.assertIsInstance(val, Sample) 34 | self.assertEqual(val.name, "foo") 35 | self.assertIsNone(val.value) 36 | 37 | # Assign from a dict 38 | val = Sample.clean_value({"name": "foo", "type": "A"}) 39 | self.assertIsInstance(val, Sample) 40 | self.assertEqual(val.name, "foo") 41 | self.assertIsNone(val.value) 42 | 43 | self.assertIsNone(Sample.clean_value(None)) 44 | with self.assertRaises(TypeError): 45 | Sample.clean_value("foo") 46 | 47 | def test_compare(self): 48 | self.assertEqual(Sample("test", 7), Sample("test", 7)) 49 | self.assertEqual(Sample(), None) 50 | self.assertNotEqual(Sample("test", 7), Sample("test", 6)) 51 | self.assertNotEqual(Sample("test", 7), None) 52 | self.assertLess(Sample("test", 6), Sample("test", 7)) 53 | self.assertLessEqual(Sample("test", 6), Sample("test", 7)) 54 | self.assertLessEqual(Sample("test", 7), Sample("test", 7)) 55 | self.assertLessEqual(Sample(), None) 56 | self.assertGreater(Sample("test", 7), Sample("test", 6)) 57 | self.assertGreater(Sample("test", 7), None) 58 | self.assertGreaterEqual(Sample("test", 7), Sample("test", 6)) 59 | self.assertGreaterEqual(Sample("test", 7), Sample("test", 7)) 60 | self.assertGreaterEqual(Sample("test", 7), None) 61 | self.assertGreaterEqual(Sample(), None) 62 | -------------------------------------------------------------------------------- /tests/test_p7m.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from contextlib import contextmanager 4 | from unittest import TestCase 5 | 6 | from a38.crypto import P7M, InvalidSignatureError 7 | 8 | # This is the CA certificate used to validate tests/data/test.txt.p7m 9 | # 10 | # The signature on the test file will expire (next expiration date: May 6 23:59:59 2024 GMT) 11 | # 12 | # To refresh it: 13 | # 14 | # 1. Sign tests/data/test.txt with a CAdES envelope 15 | # 2. Extract the signature: 16 | # openssl smime -verify -in tests/data/test.txt.p7m -inform der -noverify -signer /tmp/cert.pem -out /dev/null 17 | # 3. Get signature information: 18 | # openssl x509 -inform pem -in /tmp/cert.pem -text 19 | # 4. Compute the issuer hash to find the CA certificate: 20 | # openssl x509 -inform pem -in /tmp/cert.pem -issuer_hash 21 | # 5. Download/refresh the CA certificate database: 22 | # ./a38tool update_capath certs 23 | # 6. Find the file named with the issuer hash in certs/ 24 | # 7. Update the CA_CERT_HASH variable below with the name of the file you just 25 | # found in certs/ 26 | # 8. Replace the value of CA_CERT with its contents 27 | # 28 | CA_CERT = """ 29 | -----BEGIN CERTIFICATE----- 30 | MIIE+jCCA+KgAwIBAgIQbK2AXjA4PMWG8x+rL26V9zANBgkqhkiG9w0BAQsFADBs 31 | MQswCQYDVQQGEwJJVDEYMBYGA1UECgwPQXJ1YmFQRUMgUy5wLkEuMSEwHwYDVQQL 32 | DBhDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eUMxIDAeBgNVBAMMF0FydWJhUEVDIFMu 33 | cC5BLiBORyBDQSAzMB4XDTEwMTAyMjAwMDAwMFoXDTMwMTAyMjIzNTk1OVowbDEL 34 | MAkGA1UEBhMCSVQxGDAWBgNVBAoMD0FydWJhUEVDIFMucC5BLjEhMB8GA1UECwwY 35 | Q2VydGlmaWNhdGlvbiBBdXRob3JpdHlDMSAwHgYDVQQDDBdBcnViYVBFQyBTLnAu 36 | QS4gTkcgQ0EgMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtkY4EH 37 | G+Nh4VYLL4R5tvmX6J+AYlL2BPDUCLN92+zi9QMbsh84zbRE+om9KE8P67mST2my 38 | bhGTz6dzeK1BrQfSdKJ8AGxePzqUq+uGHGULoy4A6ey4EyqTfxY+pGzjB7OVcuiw 39 | y7iV6k1YjshIsmNjTmYOAQepZMgBmxHPnR6IW9MsAOFBBQH/vJFQDeBts/rA6lbM 40 | /VsURwzr6XOqCzwJK/csKvuE/rAaRKY+IPzah8mou//yEi4V401J1JYfPanbCJOW 41 | nIty9HaioUe5Fu2jw4UP7T5Cbw4lND1sP7HVhsVRDuTj3gF9ulJ7EBmcR/2THDZC 42 | ozD76uwuTmkm4VsCAwEAAaOCAZYwggGSMD8GCCsGAQUFBwEBBDMwMTAvBggrBgEF 43 | BQcwAYYjaHR0cDovL29jc3AuYXJ1YmFwZWMudHJ1c3RpdGFsaWEuaXQwEgYDVR0T 44 | AQH/BAgwBgEB/wIBADBGBgNVHSAEPzA9MDsGCisGAQQBgegtAQEwLTArBggrBgEF 45 | BQcCARYfaHR0cHM6Ly9jYS5hcnViYXBlYy5pdC9jcHMuaHRtbDBqBgNVHR8EYzBh 46 | MF+gXaBbhllodHRwOi8vb25zaXRlY3JsLmFydWJhcGVjLnRydXN0aXRhbGlhLml0 47 | L0FydWJhUEVDU3BBQ2VydGlmaWNhdGlvbkF1dGhvcml0eUMvTGF0ZXN0Q1JMLmNy 48 | bDArBgNVHRIEJDAipCAwHjEcMBoGA1UEAxMTR09WVlNQLUMxLTIwNDgtMS0xMDAO 49 | BgNVHQ8BAf8EBAMCAQYwKwYDVR0RBCQwIqQgMB4xHDAaBgNVBAMTE0dPVlZTUC1D 50 | MS0yMDQ4LTEtMTAwHQYDVR0OBBYEFPDARbG2NbTqXyn6gwNK3C/1s33oMA0GCSqG 51 | SIb3DQEBCwUAA4IBAQBRGwGypquxMawPV6ZN5l/2eJdaaqgnYolin1PGXJUFRQy3 52 | k5FK0Fwk/90U/j/ue83cYdsRpPVpo17LOk7hCNSFk/W2SRVGvqaM77/cVpgFwm25 53 | Ab2x5sMxwJ9Uoouba00CDl2SiYgn9KN+Bd3LHrwtpO8IkzwSE7k0kKmDLdCZTyUO 54 | ZPR8RKpwedjLJoiyXCtq9PKA3avI1R6N8yOxbK954+nSOsHfmGDP4wQi8PUJIWBm 55 | dlpHNM669BLdLwj6lpCjNI6AuP4K5Jw1qkOmcccnVWxkk0r2qNu87AlVosHpKf6G 56 | jkJbJNWfBsgjRHGg6Pq3enAf8/7DfkoCyKUzI8zZ 57 | -----END CERTIFICATE----- 58 | """ 59 | 60 | CA_CERT_HASH = "b72ed47c.0" 61 | 62 | 63 | class TestSignature(TestCase): 64 | @contextmanager 65 | def capath(self): 66 | with tempfile.TemporaryDirectory() as td: 67 | with open(os.path.join(td, CA_CERT_HASH), "wt") as fd: 68 | fd.write(CA_CERT) 69 | yield td 70 | 71 | def test_load(self): 72 | p7m = P7M("tests/data/test.txt.p7m") 73 | data = p7m.get_payload() 74 | self.assertEqual( 75 | data, 76 | "This is only a test payload.\n" 77 | "\n" 78 | "Questo è solo un payload di test.\n".encode("utf8")) 79 | 80 | def test_verify(self): 81 | p7m = P7M("tests/data/test.txt.p7m") 82 | if p7m.is_expired(): 83 | self.skipTest("test signature has expired and needs to be regenerated") 84 | with self.capath() as capath: 85 | p7m.verify_signature(capath) 86 | 87 | def test_verify_corrupted_random(self): 88 | p7m = P7M("tests/data/test.txt.p7m") 89 | if p7m.is_expired(): 90 | self.skipTest("test signature has expired and needs to be regenerated") 91 | data_mid = len(p7m.data) // 2 92 | p7m.data = p7m.data[:data_mid] + bytes([p7m.data[data_mid] + 1]) + p7m.data[data_mid + 1:] 93 | with self.capath() as capath: 94 | with self.assertRaises(InvalidSignatureError): 95 | p7m.verify_signature(capath) 96 | 97 | def test_verify_corrupted_payload(self): 98 | p7m = P7M("tests/data/test.txt.p7m") 99 | if p7m.is_expired(): 100 | self.skipTest("test signature has expired and needs to be regenerated") 101 | signed_data = p7m.get_signed_data() 102 | encap_content_info = signed_data["encap_content_info"] 103 | encap_content_info["content"] = b"All your base are belong to us" 104 | p7m.data = p7m.content_info.dump() 105 | with self.capath() as capath: 106 | with self.assertRaisesRegex(InvalidSignatureError, r"routines:CMS_verify:content verify error"): 107 | p7m.verify_signature(capath) 108 | 109 | def test_verify_noca(self): 110 | p7m = P7M("tests/data/test.txt.p7m") 111 | if p7m.is_expired(): 112 | self.skipTest("test signature has expired and needs to be regenerated") 113 | with tempfile.TemporaryDirectory() as capath: 114 | with self.assertRaisesRegex( 115 | InvalidSignatureError, r"Verify error:\s*unable to get local issuer certificate"): 116 | p7m.verify_signature(capath) 117 | --------------------------------------------------------------------------------