././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3254445 a38-0.1.8/0000755000177700017770000000000014547515512012340 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3174443 a38-0.1.8/.github/0000755000177700017770000000000014547515512013700 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/.github/workflows/0000755000177700017770000000000014547515512015735 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/.github/workflows/py.yml0000644000177700017770000000114614373374076017117 0ustar00valhallavalhalla--- name: Python A38 on: push: branches: - master pull_request: branches: - master # invoke the pipeline manually workflow_dispatch: jobs: build: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - name: Work around Apt caching issues run: make ci-workaround - name: Install OS packages run: make install-os - name: Install Pip packages run: make install-py - name: Lint run: make lint - name: Run the tests run: make test - name: Install the package run: make install-package ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/.gitignore0000644000177700017770000000012214373374076014330 0ustar00valhallavalhalla*.swp *.pyc /.mypy_cache /MANIFEST /.coverage /build /dist /htmlcov a38.egg-info/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893140.0 a38-0.1.8/CHANGELOG.md0000644000177700017770000000322514547515324014154 0ustar00valhallavalhalla# New in version UNRELEASED * Fix tests for Python 3.12 New in version 0.1.7 * Allow ranges of decimal digits in some fields, to match specifications more closely * Added `allegati` subcommand to list and extract attachments. See #32 * Allow more than one DatiRitenuta tag in DatiGeneraliDocumento, thanks @tschager, see #38 * Bump minimum supported Python version to 3.11 # New in version 0.1.6 * Generate `dati_riepilogo` with properly set `natura` (#27) * Ignore non-significant digits when computing differences between Decimal fields * a38tool diff: return exit code 0 if there are no differences * Change Prezzo Unitario decimals precision to 3 digits, thanks @matteorizzello * Fixed a rounding issue (#35), thanks @tschager * Updated signature in test certificate # New in version 0.1.5 * Added to `a38.codec` has a basic implementation of interactive editing in a text editor # New in version 0.1.4 * When a Model instance is required, allow to pass a dict matching the Model fields instead * `natura_iva` is now from 2 to 4 characters long (#18) * Added a38.consts module with constants for common enumerations (#18) * Added `DettaglioLinee.autofill_prezzo_totale` * Export `a38.fattura.$MODEL` models as `a38.$MODEL` * Implemented `a38tool yaml` to export in YAML format * Implemented loading from YAML and JSON as if they were XML * Implemented `a38tool edit` to open a fattura in a text editor using YAML or Python formats (#22) * Use UTF-8 encoding and include xml declaration when writing XML from a38tool (#19) * New module `a38.codec`, with functions to load and save from/to all supported formats * Use defusedxml for parsing if available (#24) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/LICENSE0000644000177700017770000002613514373374076013361 0ustar00valhallavalhalla Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676542731.0 a38-0.1.8/MANIFEST.in0000644000177700017770000000040214373401413014061 0ustar00valhallavalhallainclude MANIFEST.in include LICENSE include README.md include doc/README.md include tests/*.py include tests/data/* include stubs/*.pyi include stubs/dateutil/*.pyi include download-docs include document-a38 include test-coverage include requirements-lib.txt././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/Makefile0000644000177700017770000000215214373374076014005 0ustar00valhallavalhallaci-workaround: sudo sed -i 's/azure\.//' /etc/apt/sources.list sudo apt-get -o Acquire::Retries=5 update install-os: sudo apt-get -o Acquire::Retries=5 install \ openssl \ wkhtmltopdf \ eatmydata \ python3-nose2 install-py: pip install -r requirements-lib.txt pip install -r requirements-devops.txt test: sh test-coverage install-package: pip install . clean: rm --recursive --force \ $(PWD)/build \ $(PWD)/dist \ $(PWD)/htmlcov \ $(PWD)/a38.egg-info \ $(PWD)/.coverage lint: isort \ --check \ $(PWD)/a38 \ $(PWD)/tests \ setup.py flake8 \ --ignore=E126,E203,E501,W503 \ --max-line-length 120 \ --indent-size 4 \ --jobs=8 \ $(PWD)/a38 \ $(PWD)/tests \ setup.py bandit \ --recursive \ --number=3 \ -lll \ -iii \ $(PWD)/a38 \ $(PWD)/tests \ setup.py lint-dev: isort \ --atomic \ $(PWD)/a38 \ $(PWD)/tests \ setup.py $(eval PIP_DEPS=$(shell awk '{printf("%s,",$$1)}' requirements-lib.txt | sed '$$s/,$$//')) autoflake \ --imports=$(PIP_DEPS) \ --recursive \ --in-place \ --remove-unused-variables \ $(PWD)/a38 \ $(PWD)/tests \ setup.py ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3254445 a38-0.1.8/PKG-INFO0000644000177700017770000001607314547515512013444 0ustar00valhallavalhallaMetadata-Version: 2.1 Name: a38 Version: 0.1.8 Summary: parse and generate Italian Fattura Elettronica Home-page: https://github.com/Truelite/python-a38/ Author: Enrico Zini Author-email: enrico@truelite.it License: https://www.apache.org/licenses/LICENSE-2.0.html Requires-Python: >=3.11 Description-Content-Type: text/markdown Provides-Extra: cacerts Provides-Extra: formatted_python Provides-Extra: html License-File: LICENSE # Python A38 ![full workflow](https://github.com/Truelite/python-a38/actions/workflows/py.yml/badge.svg) Library to generate Italian Fattura Elettronica from Python. This library implements a declarative data model similar to Django models, that is designed to describe, validate, serialize and parse Italian Fattura Elettronica data. Only part of the specification is implemented, with more added as needs will arise. You are welcome to implement the missing pieces you need and send a pull request: the idea is to have a good, free (as in freedom) library to make billing in Italy with Python easier for everyone. The library can generate various kinds of fatture that pass validation, and can parse all the example XML files distributed by [fatturapa.gov.it](https://www.fatturapa.gov.it/it/lafatturapa/esempi/) ## Dependencies Required: dateutil, pytz, asn1crypto, and the python3 standard library. Optional: * yapf for formatting `a38tool python` output * lxml for rendering to HTML * the wkhtmltopdf command for rendering to PDF * requests for downloading CA certificates for signature verification ## `a38tool` script A simple command line wrapper to the library functions is available as `a38tool`: ```text $ a38tool --help usage: a38tool [-h] [--verbose] [--debug] {json,xml,python,diff,validate,html,pdf,update_capath} ... Handle fattura elettronica files positional arguments: {json,xml,python,diff,validate,html,pdf,update_capath} actions json output a fattura in JSON xml output a fattura in XML python output a fattura as Python code diff show the difference between two fatture validate validate the contents of a fattura html render a Fattura as HTML using a .xslt stylesheet pdf render a Fattura as PDF using a .xslt stylesheet update_capath create/update an openssl CApath with CA certificates that can be used to validate digital signatures optional arguments: -h, --help show this help message and exit --verbose, -v verbose output --debug debug output ``` See [a38tool.md](a38tool.md) for more details. ## Example code ```py import a38 from a38.validation import Validation import datetime import sys cedente_prestatore = a38.CedentePrestatore( a38.DatiAnagraficiCedentePrestatore( a38.IdFiscaleIVA("IT", "01234567890"), codice_fiscale="NTNBLN22C23A123U", anagrafica=a38.Anagrafica(denominazione="Test User"), regime_fiscale="RF01", ), a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"), iscrizione_rea=a38.IscrizioneREA( ufficio="FI", numero_rea="123456", stato_liquidazione="LN", ), contatti=a38.Contatti(email="local_part@pec_domain.it"), ) cessionario_committente = a38.CessionarioCommittente( a38.DatiAnagraficiCessionarioCommittente( a38.IdFiscaleIVA("IT", "76543210987"), anagrafica=a38.Anagrafica(denominazione="A Company SRL"), ), a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"), ) bill_number = 1 f = a38.FatturaPrivati12() f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561") f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF" f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore f.fattura_elettronica_header.cessionario_committente = cessionario_committente body = f.fattura_elettronica_body[0] body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento( tipo_documento="TD01", divisa="EUR", data=datetime.date.today(), numero=bill_number, causale=["Test billing"], ) body.dati_beni_servizi.add_dettaglio_linee( descrizione="Test item", quantita=2, unita_misura="kg", prezzo_unitario="25.50", aliquota_iva="22.00") body.dati_beni_servizi.add_dettaglio_linee( descrizione="Other item", quantita=1, unita_misura="kg", prezzo_unitario="15.50", aliquota_iva="22.00") body.dati_beni_servizi.build_dati_riepilogo() body.build_importo_totale_documento() res = Validation() f.validate(res) if res.warnings: for w in res.warnings: print(str(w), file=sys.stderr) if res.errors: for e in res.errors: print(str(e), file=sys.stderr) filename = "{}{}_{:05d}.xml".format( f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese, f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice, bill_number) tree = f.build_etree() with open(filename, "wb") as out: tree.write(out, encoding="utf-8", xml_declaration=True) ``` # Digital signatures Digital signatures on Firma Elettronica are [CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures. openssl cal verify the signatures, but not yet generate them. A patch to sign with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0) but not yet released as of 2019-02-26. ## Downloading CA certificates CA certificates for validating digital certificates are [distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature). See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati). There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but apparently no way of getting a simple bundle of certificates useable by openssl. `a38tool` has basic features to download and parse CA certificate information, and maintain a CA certificate directory: ``` a38tool update_capath certdir/ --remove-old ``` No particular effort is made to validate the downloaded certificates, besides the standard HTTPS checks performed by the [requests library](http://docs.python-requests.org/en/master/). ## Verifying signed `.p7m` files Once you have a CA certificate directory, verifying signed p7m files is quite straightforward: ``` openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/ ``` # Useful links XSLT stylesheets for displaying fatture: * From [fatturapa.gov.it](https://www.fatturapa.gov.it/), among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html) * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip) # Copyright Copyright 2019-2024 Truelite S.r.l. This software is released under the Apache License 2.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704886099.0 a38-0.1.8/README.md0000644000177700017770000001521514547477523013634 0ustar00valhallavalhalla# Python A38 ![full workflow](https://github.com/Truelite/python-a38/actions/workflows/py.yml/badge.svg) Library to generate Italian Fattura Elettronica from Python. This library implements a declarative data model similar to Django models, that is designed to describe, validate, serialize and parse Italian Fattura Elettronica data. Only part of the specification is implemented, with more added as needs will arise. You are welcome to implement the missing pieces you need and send a pull request: the idea is to have a good, free (as in freedom) library to make billing in Italy with Python easier for everyone. The library can generate various kinds of fatture that pass validation, and can parse all the example XML files distributed by [fatturapa.gov.it](https://www.fatturapa.gov.it/it/lafatturapa/esempi/) ## Dependencies Required: dateutil, pytz, asn1crypto, and the python3 standard library. Optional: * yapf for formatting `a38tool python` output * lxml for rendering to HTML * the wkhtmltopdf command for rendering to PDF * requests for downloading CA certificates for signature verification ## `a38tool` script A simple command line wrapper to the library functions is available as `a38tool`: ```text $ a38tool --help usage: a38tool [-h] [--verbose] [--debug] {json,xml,python,diff,validate,html,pdf,update_capath} ... Handle fattura elettronica files positional arguments: {json,xml,python,diff,validate,html,pdf,update_capath} actions json output a fattura in JSON xml output a fattura in XML python output a fattura as Python code diff show the difference between two fatture validate validate the contents of a fattura html render a Fattura as HTML using a .xslt stylesheet pdf render a Fattura as PDF using a .xslt stylesheet update_capath create/update an openssl CApath with CA certificates that can be used to validate digital signatures optional arguments: -h, --help show this help message and exit --verbose, -v verbose output --debug debug output ``` See [a38tool.md](a38tool.md) for more details. ## Example code ```py import a38 from a38.validation import Validation import datetime import sys cedente_prestatore = a38.CedentePrestatore( a38.DatiAnagraficiCedentePrestatore( a38.IdFiscaleIVA("IT", "01234567890"), codice_fiscale="NTNBLN22C23A123U", anagrafica=a38.Anagrafica(denominazione="Test User"), regime_fiscale="RF01", ), a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"), iscrizione_rea=a38.IscrizioneREA( ufficio="FI", numero_rea="123456", stato_liquidazione="LN", ), contatti=a38.Contatti(email="local_part@pec_domain.it"), ) cessionario_committente = a38.CessionarioCommittente( a38.DatiAnagraficiCessionarioCommittente( a38.IdFiscaleIVA("IT", "76543210987"), anagrafica=a38.Anagrafica(denominazione="A Company SRL"), ), a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"), ) bill_number = 1 f = a38.FatturaPrivati12() f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561") f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF" f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore f.fattura_elettronica_header.cessionario_committente = cessionario_committente body = f.fattura_elettronica_body[0] body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento( tipo_documento="TD01", divisa="EUR", data=datetime.date.today(), numero=bill_number, causale=["Test billing"], ) body.dati_beni_servizi.add_dettaglio_linee( descrizione="Test item", quantita=2, unita_misura="kg", prezzo_unitario="25.50", aliquota_iva="22.00") body.dati_beni_servizi.add_dettaglio_linee( descrizione="Other item", quantita=1, unita_misura="kg", prezzo_unitario="15.50", aliquota_iva="22.00") body.dati_beni_servizi.build_dati_riepilogo() body.build_importo_totale_documento() res = Validation() f.validate(res) if res.warnings: for w in res.warnings: print(str(w), file=sys.stderr) if res.errors: for e in res.errors: print(str(e), file=sys.stderr) filename = "{}{}_{:05d}.xml".format( f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese, f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice, bill_number) tree = f.build_etree() with open(filename, "wb") as out: tree.write(out, encoding="utf-8", xml_declaration=True) ``` # Digital signatures Digital signatures on Firma Elettronica are [CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures. openssl cal verify the signatures, but not yet generate them. A patch to sign with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0) but not yet released as of 2019-02-26. ## Downloading CA certificates CA certificates for validating digital certificates are [distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature). See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati). There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but apparently no way of getting a simple bundle of certificates useable by openssl. `a38tool` has basic features to download and parse CA certificate information, and maintain a CA certificate directory: ``` a38tool update_capath certdir/ --remove-old ``` No particular effort is made to validate the downloaded certificates, besides the standard HTTPS checks performed by the [requests library](http://docs.python-requests.org/en/master/). ## Verifying signed `.p7m` files Once you have a CA certificate directory, verifying signed p7m files is quite straightforward: ``` openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/ ``` # Useful links XSLT stylesheets for displaying fatture: * From [fatturapa.gov.it](https://www.fatturapa.gov.it/), among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html) * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip) # Copyright Copyright 2019-2024 Truelite S.r.l. This software is released under the Apache License 2.0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/a38/0000755000177700017770000000000014547515512012733 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/__init__.py0000644000177700017770000000003714373374076015051 0ustar00valhallavalhallafrom .fattura import * # noqa ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/builder.py0000644000177700017770000000514114373374076014741 0ustar00valhallavalhalla# This module builds XML trees but does not parse them, so it does not need # defusedxml import xml.etree.ElementTree as ET from contextlib import contextmanager try: import lxml.etree HAVE_LXML = True except ModuleNotFoundError: HAVE_LXML = False class Builder: def __init__(self, etreebuilder=None): if etreebuilder is None: etreebuilder = ET.TreeBuilder() self.etreebuilder = etreebuilder self.default_namespace = None def _decorate_tag_name(self, tag: str): if self.default_namespace is not None and not tag.startswith("{"): return "{" + self.default_namespace + "}" + tag return tag def add(self, tag: str, value: str, **attrs): tag = self._decorate_tag_name(tag) self.etreebuilder.start(tag, attrs) if value is not None: self.etreebuilder.data(value) self.etreebuilder.end(tag) @contextmanager def element(self, tag: str, **attrs): tag = self._decorate_tag_name(tag) self.etreebuilder.start(tag, attrs) yield self self.etreebuilder.end(tag) @contextmanager def override_default_namespace(self, ns): b = Builder(self.etreebuilder) b.default_namespace = ns yield b def get_tree(self): root = self.etreebuilder.close() return ET.ElementTree(root) if HAVE_LXML: class LXMLBuilder: def __init__(self, etreebuilder=None): if etreebuilder is None: etreebuilder = lxml.etree.TreeBuilder() self.etreebuilder = etreebuilder self.default_namespace = None def _decorate_tag_name(self, tag: str): if self.default_namespace is not None and not tag.startswith("{"): return "{" + self.default_namespace + "}" + tag return tag def add(self, tag: str, value: str, **attrs): tag = self._decorate_tag_name(tag) self.etreebuilder.start(tag, attrs) if value is not None: self.etreebuilder.data(value) self.etreebuilder.end(tag) @contextmanager def element(self, tag: str, **attrs): tag = self._decorate_tag_name(tag) self.etreebuilder.start(tag, attrs) yield self self.etreebuilder.end(tag) @contextmanager def override_default_namespace(self, ns): b = Builder(self.etreebuilder) b.default_namespace = ns yield b def get_tree(self): root = self.etreebuilder.close() return lxml.etree.ElementTree(root) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/a38/codec.py0000644000177700017770000002360714547470605014375 0ustar00valhallavalhallafrom __future__ import annotations import io import json import logging import os import subprocess import tempfile try: from defusedxml import ElementTree as ET except ModuleNotFoundError: import xml.etree.ElementTree as ET from typing import (Any, BinaryIO, Dict, List, Optional, Sequence, TextIO, Type, Union) try: import ruamel.yaml yaml = None except ModuleNotFoundError: ruamel = None try: import yaml except ModuleNotFoundError: yaml = None from . import crypto from .fattura import auto_from_dict, auto_from_etree from .models import Model log = logging.getLogger("codec") if ruamel is not None: def _load_yaml(fd: TextIO): yaml_loader = ruamel.yaml.YAML(typ="safe", pure=True) return yaml_loader.load(fd) def _write_yaml(data: Dict[str, Any], file: TextIO): yaml = ruamel.yaml.YAML(typ="safe") yaml.default_flow_style = False yaml.allow_unicode = True yaml.explicit_start = True yaml.dump(data, file) elif yaml is not None: def _load_yaml(fd: TextIO): return yaml.load(fd, Loader=yaml.CLoader) def _write_yaml(data: Dict[str, Any], file: TextIO): yaml.dump( data, stream=file, default_flow_style=False, sort_keys=False, allow_unicode=True, explicit_start=True, Dumper=yaml.CDumper) else: def _load_yaml(fd: TextIO): raise NotImplementedError("loading YAML requires ruamel.yaml or PyYAML to be installed") def _write_yaml(data: Dict[str, Any], file: TextIO): raise NotImplementedError("writing YAML requires ruamel.yaml or PyYAML to be installed") class Codec: """ Base class for format-specific reading and writing of fatture """ # If True, file objects are expected to be open in binary mode binary = False def load( self, pathname: str, model: Optional[Type[Model]]) -> Model: """ Load a fattura from a file. If model is provided it will be used for loading, otherwise the Model type will be autodetected """ raise NotImplementedError(f"{self.__class__.__name__}.load is not implemented") def write_file(self, f: Model, file: Union[TextIO, BinaryIO]): """ Write a fattura to the given file deescriptor. """ raise NotImplementedError(f"{self.__class__.__name__}.write_file is not implemented") def save(self, f: Model, pathname: str): """ Write a fattura to the given file """ with open(pathname, "wb" if self.binary else "wt") as fd: self.write_file(f, fd) def interactive_edit(self, f: Model) -> Optional[Model]: """ Edit the given model in an interactive editor, using the format of this codec """ with io.StringIO() as orig: self.write_file(f, orig) return self.edit_buffer(orig.getvalue(), model=f.__class__) def edit_buffer(self, buf: str, model: Optional[Type[Model]] = None) -> Optional[Model]: """ Open an editor on buf and return the edited fattura. Return None if editing did not change the contents. """ editor = os.environ.get("EDITOR", "sensible-editor") current = buf error = None while True: with tempfile.NamedTemporaryFile( mode="wt", suffix=f".{self.EXTENSIONS[0]}") as tf: # Write out the current buffer tf.write(current) if error is not None: tf.write(f"# ERROR: {error}") error = None tf.flush() # Run the editor on it subprocess.run([editor, tf.name], check=True) # Reopen by name in case the editor did not write on the same # inode with open(tf.name, "rt") as fd: lines = [] for line in fd: if line.startswith("# ERROR: "): continue lines.append(line) edited = "".join(lines) if edited == current: return None try: return self.load(tf.name, model=model) except Exception as e: log.error("%s: cannot load edited file: %s", tf.name, e) error = str(e) class P7M(Codec): """ P7M codec, that only supports loading """ EXTENSIONS = ("p7m",) def load( self, pathname: str, model: Optional[Type[Model]] = None) -> Model: p7m = crypto.P7M(pathname) return p7m.get_fattura() class JSON(Codec): """ JSON codec. `indent` represents the JSON structure indentation, and can be None to output everything in a single line. `end` is a string that gets appended to the JSON structure. """ EXTENSIONS = ("json",) def __init__(self, indent: Optional[int] = 1, end="\n"): self.indent = indent self.end = end def load( self, pathname: str, model: Optional[Type[Model]] = None) -> Model: with open(pathname, "rt") as fd: data = json.load(fd) if model: return model(**data) else: return auto_from_dict(data) def write_file(self, f: Model, file: TextIO): json.dump(f.to_jsonable(), file, indent=self.indent) if self.end is not None: file.write(self.end) class YAML(Codec): """ YAML codec """ EXTENSIONS = ("yaml", "yml") def load( self, pathname: str, model: Optional[Type[Model]] = None) -> Model: with open(pathname, "rt") as fd: data = _load_yaml(fd) if model: return model(**data) else: return auto_from_dict(data) def write_file(self, f: Model, file: TextIO): _write_yaml(f.to_jsonable(), file) class Python(Codec): """ Python codec. `namespace` defines what namespace is used to refer to `a38` models. `None` means use a default, `False` means not to use a namespace, a string defines which namespace to use. `unformatted` can be set to True to skip code formatting. The code will be written with just the expression to build the fattura. The code assumes `import datetime` and `from decimal import Decimal`. If loadable is True, the file is written as a Python source that creates a `fattura` variable with the fattura, with all the imports that are needed. This generates a python file that can be loaded with load(). Note that loading Python fatture executes arbitrary Python code! """ EXTENSIONS = ("py",) def __init__( self, namespace: Union[None, bool, str] = "a38", unformatted: bool = False, loadable: bool = False): self.namespace = namespace self.unformatted = unformatted self.loadable = loadable def load( self, pathname: str, model: Optional[Type[Model]] = None) -> Model: with open(pathname, "rt") as fd: code = compile(fd.read(), pathname, 'exec') loc = {} exec(code, {}, loc) return loc["fattura"] def write_file(self, f: Model, file: TextIO): code = f.to_python(namespace=self.namespace) if not self.unformatted: try: from yapf.yapflib import yapf_api except ModuleNotFoundError: pass else: code, changed = yapf_api.FormatCode(code) if self.loadable: print("import datetime", file=file) print("from decimal import Decimal", file=file) if self.namespace: print("import", self.namespace, file=file) elif self.namespace is False: print("from a38.fattura import *", file=file) else: print("import a38", file=file) print(file=file) print("fattura = ", file=file, end="") print(code, file=file) class XML(Codec): """ XML codec """ EXTENSIONS = ("xml",) binary = True def load( self, pathname: str, model: Optional[Type[Model]] = None) -> Model: tree = ET.parse(pathname) return auto_from_etree(tree.getroot()) def write_file(self, f: Model, file: BinaryIO): tree = f.build_etree() tree.write(file, encoding="utf-8", xml_declaration=True) file.write(b"\n") class Codecs: """ A collection of codecs """ ALL_CODECS = (XML, P7M, JSON, YAML, Python) def __init__( self, include: Optional[Sequence[Type[Codec]]] = None, exclude: Optional[Sequence[Type[Codec]]] = (Python,)): """ if `include` is not None, only codecs in that list are used. If `exclude` is not None, all codecs are used except the given one. If neither `include` nor `exclude` are None, all codecs are used. By default, `exclude` is not None but it is set to exclude Python. """ self.codecs: List[Type[Codec]] if include is not None and exclude is not None: raise ValueError("include and exclude cannot both be set") elif include is not None: self.codecs = list(include) elif exclude is not None: self.codecs = [c for c in self.ALL_CODECS if c not in exclude] else: self.codecs = list(self.ALL_CODECS) def codec_from_filename(self, pathname: str) -> Type[Codec]: """ Infer a Codec class from the extension of the file at `pathname`. """ ext = pathname.rsplit(".", 1)[1].lower() for c in self.codecs: if ext in c.EXTENSIONS: return c ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/consts.py0000644000177700017770000001772414373374076014636 0ustar00valhallavalhalla# see table at page 26 for the PDF document # GUIDA ALLA COMPILAZIONE DELLE FATTURE ELETTRONICHE E DELL’ESTEROMETRO # from AdE (Agenzia Delle Entrate), version 1.6 - 2022/02/04 # https://www.agenziaentrate.gov.it/portale/documents/20143/451259/Guida_compilazione-FE_2021_07_07.pdf/e6fcdd04-a7bd-e6f2-ced4-cac04403a768 # see also: # - https://agenziaentrate.gov.it/portale/documents/20143/296703/Variazioni+alle+specifiche+tecniche+fatture+elettroniche2021-07-02.pdf # noqa # - 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 NATURA_IVA = ( "N1", "N2", "N2.1", # non soggette ad IVA ai sensi degli artt. da 7 a 7-septies del D.P.R. n. 633/72 "N2.2", # non soggette - altri casi "N3", "N3.1", # non imponibili - esportazioni "N3.2", # non imponibili - cessioni intracomunitarie "N3.3", # non imponibili - cessioni verso San Marino "N3.4", # non imponibili - operazioni assimilate alle cessioni all'esportazione "N3.5", # non imponibili - a seguito di dichiarazioni d'intento "N3.6", # non imponibili - altre operazioni "N4", "N5", "N6", "N6.1", # inversione contabile - cessione di rottami e altri materiali di recupero "N6.2", # inversione contabile – cessione di oro e argento ai sensi della # legge 7/2000 nonché di oreficeria usata ad OPO "N6.3", # inversione contabile - subappalto nel settore edile "N6.4", # inversione contabile - cessione di fabbricati "N6.5", # inversione contabile - cessione di telefoni cellulari "N6.6", # inversione contabile - cessione di prodotti elettronici "N6.7", # inversione contabile - prestazioni comparto edile e settori connessi "N6.8", # inversione contabile - operazioni settore energetico "N6.9", # inversione contabile - altri casi "N7", ) # see pages 1 to 25 for the PDF document # GUIDA ALLA COMPILAZIONE DELLE FATTURE ELETTRONICHE E DELL’ESTEROMETRO # from AdE (Agenzia Delle Entrate), version 1.6 - 2022/02/04 # https://www.agenziaentrate.gov.it/portale/documents/20143/451259/Guida_compilazione-FE_2021_07_07.pdf/e6fcdd04-a7bd-e6f2-ced4-cac04403a768 TIPO_DOCUMENTO = ( "TD01", # FATTURA "TD02", # ACCONTO/ANTICIPO SU FATTURA "TD03", # ACCONTO/ANTICIPO SU PARCELLA "TD04", # NOTA DI CREDITO "TD05", # NOTA DI DEBITO "TD06", # PARCELLA "TD07", # FATTURA SEMPLIFICATA "TD08", # NOTA DI CREDITO SEMPLIFICATA "TD09", # NOTA DI DEBITO SEMPLIFICATA "TD16", # INTEGRAZIONE FATTURA DA REVERSE CHARGE INTERNO "TD17", # INTEGRAZIONE/AUTOFATTURA PER ACQUISTO SERVIZI DALL'ESTERO "TD18", # INTEGRAZIONE PER ACQUISTO DI BENI INTRACOMUNITARI "TD19", # INTEGRAZIONE/AUTOFATTURA PER ACQUISTO DI BENI EX ART. 17 C.2 D.P.R. 633/72 "TD20", # AUTOFATTURA PER REGOLARIZZAZIONE E INTEGRAZIONE DELLE FATTURE # (EX ART. 6 COMMI 8 E 9-BIS D. LGS. 471/97 O ART. 46 C.5 D.L. 331/93) "TD21", # AUTOFATTURA PER SPLAFONAMENTO "TD22", # ESTRAZIONE BENI DA DEPOSITO IVA "TD23", # ESTRAZIONE BENI DA DEPOSITO IVA CON VERSAMENTO DELL'IVA "TD24", # FATTURA DIFFERITA DI CUI ALL'ART. 21, COMMA 4, TERZO PERIODO, LETT. A), DEL D.P.R. N. 633/72 "TD25", # FATTURA DIFFERITA DI CUI ALL'ART. 21, COMMA 4, TERZO PERIODO LETT. B), DEL D.P.R. N. 633/72 "TD26", # CESSIONE DI BENI AMMORTIZZABILI E PER PASSAGGI INTERNI (EX ART. 36 D.P.R. 633/72) "TD27", # FATTURA PER AUTOCONSUMO O PER CESSIONI GRATUITE SENZA RIVALSA ) # Copied from Documentazione valida a partire dal 1 ottobre 2020 # Rappresentazione tabellare del tracciato fattura ordinaria - excel REGIME_FISCALE = ( "RF01", # Ordinario "RF02", # Contribuenti minimi (art.1, c.96-117, L. 244/07) "RF04", # Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR 633/72) "RF05", # Vendita sali e tabacchi (art.74, c.1, DPR. 633/72) "RF06", # Commercio fiammiferi (art.74, c.1, DPR 633/72) "RF07", # Editoria (art.74, c.1, DPR 633/72) "RF08", # Gestione servizi telefonia pubblica (art.74, c.1, DPR 633/72) "RF09", # Rivendita documenti di trasporto pubblico e di sosta (art.74, c.1, DPR 633/72) "RF10", # Intrattenimenti, giochi e altre attività di cui alla tariffa # allegata al DPR 640/72 (art.74, c.6, DPR 633/72) "RF11", # Agenzie viaggi e turismo (art.74-ter, DPR 633/72) "RF12", # Agriturismo (art.5, c.2, L. 413/91) "RF13", # Vendite a domicilio (art.25-bis, c.6, DPR 600/73) "RF14", # Rivendita beni usati, oggetti d’arte, d’antiquariato o da collezione (art.36, DL 41/95) "RF15", # Agenzie di vendite all’asta di oggetti d’arte, antiquariato o da collezione (art.40-bis, DL 41/95) "RF16", # IVA per cassa P.A. (art.6, c.5, DPR 633/72) "RF17", # IVA per cassa (art. 32-bis, DL 83/2012) "RF18", # Altro "RF19", # Regime forfettario (art.1, c.54-89, L. 190/2014) ) # Copied from Documentazione valida a partire dal 1 ottobre 2020 # Rappresentazione tabellare del tracciato fattura ordinaria - excel TIPO_CASSA = ( "TC01", # Cassa nazionale previdenza e assistenza avvocati e procuratori legali "TC02", # Cassa previdenza dottori commercialisti "TC03", # Cassa previdenza e assistenza geometri "TC04", # Cassa nazionale previdenza e assistenza ingegneri e architetti liberi professionisti "TC05", # Cassa nazionale del notariato "TC06", # Cassa nazionale previdenza e assistenza ragionieri e periti commerciali "TC07", # Ente nazionale assistenza agenti e rappresentanti di commercio (ENASARCO) "TC08", # Ente nazionale previdenza e assistenza consulenti del lavoro (ENPACL) "TC09", # Ente nazionale previdenza e assistenza medici (ENPAM) "TC10", # Ente nazionale previdenza e assistenza farmacisti (ENPAF) "TC11", # Ente nazionale previdenza e assistenza veterinari (ENPAV) "TC12", # Ente nazionale previdenza e assistenza impiegati dell'agricoltura (ENPAIA) "TC13", # Fondo previdenza impiegati imprese di spedizione e agenzie marittime "TC14", # Istituto nazionale previdenza giornalisti italiani (INPGI) "TC15", # Opera nazionale assistenza orfani sanitari italiani (ONAOSI) "TC16", # Cassa autonoma assistenza integrativa giornalisti italiani (CASAGIT) "TC17", # Ente previdenza periti industriali e periti industriali laureati (EPPI) "TC18", # Ente previdenza e assistenza pluricategoriale (EPAP) "TC19", # Ente nazionale previdenza e assistenza biologi (ENPAB) "TC20", # Ente nazionale previdenza e assistenza professione infermieristica (ENPAPI) "TC21", # Ente nazionale previdenza e assistenza psicologi (ENPAP) "TC22", # INPS ) # Copied from Documentazione valida a partire dal 1 ottobre 2020 # Rappresentazione tabellare del tracciato fattura ordinaria - excel MODALITA_PAGAMENTO = ( "MP01", # contanti "MP02", # assegno "MP03", # assegno circolare "MP04", # contanti presso Tesoreria "MP05", # bonifico "MP06", # vaglia cambiario "MP07", # bollettino bancario "MP08", # carta di pagamento "MP09", # RID "MP10", # RID utenze "MP11", # RID veloce "MP12", # RIBA "MP13", # MAV "MP14", # quietanza erario "MP15", # giroconto su conti di contabilità speciale "MP16", # domiciliazione bancaria "MP17", # domiciliazione postale "MP18", # bollettino di c/c postale "MP19", # SEPA Direct Debit "MP20", # SEPA Direct Debit CORE "MP21", # SEPA Direct Debit B2B "MP22", # Trattenuta su somme già riscosse "MP23", # PagoPA ) # Copied from Documentazione valida a partire dal 1 ottobre 2020 # Rappresentazione tabellare del tracciato fattura ordinaria - excel TIPO_RITENUTA = ( "RT01", # ritenuta persone fisiche "RT02", # ritenuta persone giuridiche "RT03", # contributo INPS "RT04", # contributo ENASARCO "RT05", # contributo ENPAM "RT06", # altro contributo previdenziale ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/crypto.py0000644000177700017770000000754514373374076014645 0ustar00valhallavalhallaimport base64 import binascii import datetime import io import subprocess try: from defusedxml import ElementTree as ET except ModuleNotFoundError: import xml.etree.ElementTree as ET from typing import BinaryIO, Union from asn1crypto.cms import ContentInfo from . import fattura as a38 class SignatureVerificationError(Exception): pass class InvalidSignatureError(SignatureVerificationError): pass class SignerCertificateError(SignatureVerificationError): pass class P7M: """ Parse a Fattura Elettronica encoded as a .p7m file """ def __init__(self, data: Union[str, bytes, BinaryIO]): """ If data is a string, it is taken as a file name. If data is bytes, it is taken as p7m data. Otherwise, data is taken as a file-like object that reads bytes data. """ if isinstance(data, str): with open(data, "rb") as fd: self.data = fd.read() elif isinstance(data, bytes): self.data = data else: self.data = data.read() # Data might potentially be base64 encoded try: self.data = base64.b64decode(self.data, validate=True) except binascii.Error: pass self.content_info = ContentInfo.load(self.data) def is_expired(self) -> bool: """ Check if the signature has expired """ now = datetime.datetime.utcnow() signed_data = self.get_signed_data() for c in signed_data["certificates"]: if c.name != "certificate": # The signatures I've seen so far use 'certificate' only continue expiration_date = c.chosen["tbs_certificate"]["validity"]["not_after"].chosen.native.replace(tzinfo=None) if expiration_date <= now: return True return False def get_signed_data(self): """ Return the SignedData part of the P7M file """ if self.content_info["content_type"].native != "signed_data": raise RuntimeError("p7m data is not an instance of signed_data") signed_data = self.content_info["content"] if signed_data["version"].native != "v1": raise RuntimeError(f"ContentInfo/SignedData.version is {signed_data['version'].native} instead of v1") return signed_data def get_payload(self): """ Return the raw signed data """ signed_data = self.get_signed_data() encap_content_info = signed_data["encap_content_info"] return encap_content_info["content"].native def get_fattura(self): """ Return the parsed XML data """ data = io.BytesIO(self.get_payload()) tree = ET.parse(data) return a38.auto_from_etree(tree.getroot()) def verify_signature(self, certdir): """ Verify the signature on the file """ res = subprocess.run([ "openssl", "cms", "-verify", "-inform", "DER", "-CApath", certdir, "-noout"], input=self.data, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) # From openssl cms manpage: # 0 The operation was completely successfully. # 1 An error occurred parsing the command options. # 2 One of the input files could not be read. # 3 An error occurred creating the CMS file or when reading the MIME message. # 4 An error occurred decrypting or verifying the message. # 5 The message was verified correctly but an error occurred writing out the signers certificates. if res.returncode == 0: pass elif res.returncode == 4: raise InvalidSignatureError(res.stderr) elif res.returncode == 5: raise SignerCertificateError(res.stderr) else: raise RuntimeError(res.stderr) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/diff.py0000644000177700017770000000437514373374076014233 0ustar00valhallavalhallafrom typing import Any, List, Optional from . import fields from .traversal import Annotation, Traversal class Difference(Annotation): def __init__(self, prefix: Optional[str], field: "fields.Field", first: Any, second: Any): super().__init__(prefix, field) self.first = first self.second = second def __str__(self): return "{}: first: {}, second: {}".format( self.qualified_field, self.field.to_str(self.first), self.field.to_str(self.second)) class MissingOne(Difference): def __str__(self): if self.first is None: return "{}: first is not set".format(self.qualified_field) else: return "{}: second is not set".format(self.qualified_field) class ExtraItems(Difference): def __str__(self): if len(self.first) > len(self.second): diff = len(self.first) - len(self.second) longer = "first" else: diff = len(self.second) - len(self.first) longer = "second" if diff == 1: return "{}: {} has 1 extra element".format(self.qualified_field, longer) else: return "{}: {} has {} extra elements".format(self.qualified_field, longer, diff) class Diff(Traversal): def __init__(self, prefix: Optional[str] = None, differences: Optional[List[Difference]] = None): super().__init__(prefix) self.differences: List[Difference] if differences is None: self.differences = [] else: self.differences = differences def with_prefix(self, prefix: str): return Diff(prefix, self.differences) def add_different(self, field: "fields.Field", first: Any, second: Any): self.differences.append(Difference(self.prefix, field, first, second)) def add_only_first(self, field: "fields.Field", first: Any): self.differences.append(MissingOne(self.prefix, field, first, None)) def add_only_second(self, field: "fields.Field", second: Any): self.differences.append(MissingOne(self.prefix, field, None, second)) def add_different_length(self, field: "fields.Field", first: Any, second: Any): self.differences.append(ExtraItems(self.prefix, field, first, second)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/a38/fattura.py0000644000177700017770000010432214547470605014760 0ustar00valhallavalhallafrom __future__ import annotations import re from typing import TYPE_CHECKING, Any, Dict, Union from . import consts, fields, models if TYPE_CHECKING: import xml.etree.ElementTree as ET from .fattura_semplificata import FatturaElettronicaSemplificata # # This file describes the data model of the Italian Fattura Elettronica. # # Models and fields are inspired from Django's ORM. # # XML tag names are built automatically from the field names, and can be # specified explicitly with the xmltag argument. # # Models can be used as fields using `fields.ModelField`. Specifying a Model # class as a field, automatically wraps it in a `ModelField`. # __all__ = [] def export(el): """ Add a symbol to __all__ for export """ global __all__ __all__.append(el.__name__) return el NS = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2" NS10 = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.0" NS_SIG = "http://www.w3.org/2000/09/xmldsig#" class FullNameMixin: """ Helper for classes that have the nome+cognome/denominazione way of naming. Validate that nome+cognome and denominazione are mutually exclusive, and provide a full_name property that returns whichever is set. """ @property def full_name(self): """ Return denominazione or "{nome} {cognome}", whichever is set. If none are set, return None """ if self.denominazione is not None: return self.denominazione elif self.nome is not None and self.cognome is not None: return self.nome + " " + self.cognome else: return None def validate_model(self, validation): super().validate_model(validation) if self.denominazione is None: if self.nome is None and self.cognome is None: validation.add_error( ( self._meta["nome"], self._meta["cognome"], self._meta["denominazione"], ), "nome and cognome, or denominazione, must be set", ) elif self.nome is None: validation.add_error( self._meta["nome"], "nome and cognome must both be set if denominazione is empty", ) elif self.cognome is None: validation.add_error( self._meta["cognome"], "nome and cognome must both be set if denominazione is empty", ) else: should_not_be_set = [] if self.nome is not None: should_not_be_set.append(self._meta["nome"]) if self.cognome is not None: should_not_be_set.append(self._meta["cognome"]) if should_not_be_set: validation.add_error( should_not_be_set, "{} must not be set if denominazione is not empty".format( " and ".join(x.name for x in should_not_be_set) ), ) @export class IdFiscale(models.Model): id_paese = fields.StringField(length=2) id_codice = fields.StringField(max_length=28) @export class IdTrasmittente(IdFiscale): pass @export class IdFiscaleIVA(IdFiscale): pass @export class ContattiTrasmittente(models.Model): telefono = fields.StringField(min_length=5, max_length=12, null=True) email = fields.StringField(min_length=7, max_length=256, null=True) @export class DatiTrasmissione(models.Model): id_trasmittente = IdTrasmittente progressivo_invio = fields.ProgressivoInvioField() formato_trasmissione = fields.StringField(length=5, choices=("FPR12", "FPA12")) codice_destinatario = fields.StringField( null=True, min_length=6, max_length=7, default="0000000" ) contatti_trasmittente = fields.ModelField(ContattiTrasmittente, null=True) pec_destinatario = fields.StringField( null=True, min_length=8, max_length=256, xmltag="PECDestinatario" ) def validate_model(self, validation): super().validate_model(validation) if self.codice_destinatario is None and self.pec_destinatario is None: validation.add_error( (self._meta["codice_destinatario"], self._meta["pec_destinatario"]), "one of codice_destinatario or pec_destinatario must be set", ) # Se la fattura deve essere recapitata ad un soggetto che intende # ricevere le fatture elettroniche attraverso il canale PEC, il campo # deve essere valorizzato con sette zeri (“0000000”) e deve essere # valorizzato il campo PECDestinatario if self.pec_destinatario is None and self.codice_destinatario == "0000000": validation.add_error( (self._meta["codice_destinatario"], self._meta["pec_destinatario"]), "pec_destinatario has no value while codice_destinatario has value 0000000", code="00426", ) if ( self.pec_destinatario is not None and self.codice_destinatario is not None and self.codice_destinatario != "0000000" ): validation.add_error( (self._meta["codice_destinatario"], self._meta["pec_destinatario"]), "pec_destinatario has value while codice_destinatario has value 0000000", code="00426", ) if self.formato_trasmissione == "FPA12" and len(self.codice_destinatario) == 7: validation.add_error( self._meta["codice_destinatario"], "codice_destinatario has 7 characters on a Fattura PA", code="00427", ) if self.formato_trasmissione == "FPR12" and len(self.codice_destinatario) == 6: validation.add_error( self._meta["codice_destinatario"], "codice_destinatario has 6 characters on a Fattura Privati", code="00427", ) @export class Anagrafica(FullNameMixin, models.Model): denominazione = fields.StringField(max_length=80, null=True) nome = fields.StringField(max_length=60, null=True) cognome = fields.StringField(max_length=60, null=True) titolo = fields.StringField(min_length=2, max_length=10, null=True) cod_eori = fields.StringField( xmltag="CodEORI", min_length=13, max_length=17, null=True ) @export class DatiAnagraficiCedentePrestatore(models.Model): __xmltag__ = "DatiAnagrafici" id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica albo_professionale = fields.StringField(max_length=60, null=True) provincia_albo = fields.StringField(length=2, null=True) numero_iscrizione_albo = fields.StringField(max_length=60, null=True) data_iscrizione_albo = fields.DateField(null=True) regime_fiscale = fields.StringField(length=4, choices=consts.REGIME_FISCALE) @export class IndirizzoType(models.Model): indirizzo = fields.StringField(max_length=60) numero_civico = fields.StringField(max_length=8, null=True) cap = fields.StringField(xmltag="CAP", length=5) comune = fields.StringField(max_length=60) provincia = fields.StringField(length=2, null=True) nazione = fields.StringField(length=2) @export class Sede(IndirizzoType): pass @export class IscrizioneREA(models.Model): ufficio = fields.StringField(length=2) numero_rea = fields.StringField(xmltag="NumeroREA", max_length=20) capitale_sociale = fields.StringField(min_length=4, max_length=15, null=True) socio_unico = fields.StringField(length=2, choices=("SU", "SM"), null=True) stato_liquidazione = fields.StringField(length=2, choices=("LS", "LN")) @export class Contatti(models.Model): telefono = fields.StringField(min_length=5, max_length=12, null=True) fax = fields.StringField(min_length=5, max_length=12, null=True) email = fields.StringField(min_length=7, max_length=256, null=True) @export class StabileOrganizzazione(IndirizzoType): pass @export class CedentePrestatore(models.Model): dati_anagrafici = DatiAnagraficiCedentePrestatore sede = Sede stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True) iscrizione_rea = fields.ModelField(IscrizioneREA, null=True) contatti = fields.ModelField(Contatti, null=True) riferimento_amministrazione = fields.StringField(max_length=20, null=True) @export class DatiAnagraficiCessionarioCommittente(models.Model): __xmltag__ = "DatiAnagrafici" id_fiscale_iva = fields.ModelField(IdFiscaleIVA, null=True) codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica def validate_model(self, validation): super().validate_model(validation) if self.id_fiscale_iva is None and self.codice_fiscale is None: validation.add_error( (self._meta["id_fiscale_iva"], self._meta["codice_fiscale"]), "at least one of id_fiscale_iva and codice_fiscale needs to have a value", code="00417", ) @export class RappresentanteFiscale(FullNameMixin, models.Model): id_fiscale_iva = fields.ModelField(IdFiscaleIVA, null=True) denominazione = fields.StringField(max_length=80, null=True) nome = fields.StringField(max_length=60, null=True) cognome = fields.StringField(max_length=60, null=True) @export class CessionarioCommittente(models.Model): dati_anagrafici = DatiAnagraficiCessionarioCommittente sede = Sede stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True) rappresentante_fiscale = fields.ModelField(RappresentanteFiscale, null=True) @export class DatiAnagraficiRappresentante(models.Model): __xmltag__ = "DatiAnagrafici" id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica @export class RappresentanteFiscaleCedentePrestatore(models.Model): __xmltag__ = "RappresentanteFiscale" dati_anagrafici = DatiAnagraficiRappresentante @export class DatiAnagraficiTerzoIntermediario(models.Model): __xmltag__ = "DatiAnagrafici" id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica @export class TerzoIntermediarioOSoggettoEmittente(models.Model): dati_anagrafici = DatiAnagraficiTerzoIntermediario @export class FatturaElettronicaHeader(models.Model): dati_trasmissione = DatiTrasmissione cedente_prestatore = CedentePrestatore rappresentante_fiscale = models.ModelField( RappresentanteFiscaleCedentePrestatore, null=True ) cessionario_committente = CessionarioCommittente terzo_intermediario_o_soggetto_emittente = models.ModelField( TerzoIntermediarioOSoggettoEmittente, null=True ) soggetto_emittente = fields.StringField(length=2, choices=("CC", "TZ"), null=True) @export class DatiRitenuta(models.Model): tipo_ritenuta = fields.StringField(length=4, choices=consts.TIPO_RITENUTA) importo_ritenuta = fields.DecimalField(max_length=15) aliquota_ritenuta = fields.DecimalField(max_length=6) causale_pagamento = fields.StringField(max_length=2) @export class DatiBollo(models.Model): bollo_virtuale = fields.StringField(length=2, choices=("SI",)) importo_bollo = fields.DecimalField(max_length=15) @export class DatiCassaPrevidenziale(models.Model): tipo_cassa = fields.StringField(length=4, choices=consts.TIPO_CASSA) al_cassa = fields.DecimalField(max_length=6) importo_contributo_cassa = fields.DecimalField(max_length=15) imponibile_cassa = fields.DecimalField(max_length=15) aliquota_iva = fields.DecimalField(max_length=6, xmltag="AliquotaIVA") ritenuta = fields.StringField(length=2, choices=("SI",), null=True) natura = fields.StringField( min_length=2, max_length=4, choices=consts.NATURA_IVA, null=True ) riferimento_amministrazione = fields.StringField(max_length=20, null=True) def validate_model(self, validation): super().validate_model(validation) if self.aliquota_iva == 0 and self.natura is None: validation.add_error( self._meta["natura"], "field is empty while aliquota_iva is zero", code="00413", ) if self.aliquota_iva != 0 and self.natura is not None: validation.add_error( self._meta["natura"], "field has value while aliquota_iva is not zero", code="00414", ) @export class ScontoMaggiorazione(models.Model): tipo = fields.StringField(length=2, choices=("SC", "MG")) percentuale = fields.DecimalField(max_length=6, null=True) importo = fields.DecimalField(max_length=15, null=True) @export class DatiGeneraliDocumento(models.Model): tipo_documento = fields.StringField(length=4, choices=consts.TIPO_DOCUMENTO) divisa = fields.StringField() data = fields.DateField() numero = fields.StringField(max_length=20) dati_ritenuta = fields.ModelListField(DatiRitenuta, null=True) dati_bollo = fields.ModelField(DatiBollo, null=True) dati_cassa_previdenziale = fields.ModelListField(DatiCassaPrevidenziale, null=True) sconto_maggiorazione = fields.ModelListField(ScontoMaggiorazione, null=True) importo_totale_documento = fields.DecimalField(max_length=15, null=True) arrotondamento = fields.DecimalField(max_length=15, null=True) causale = fields.ListField(fields.StringField(max_length=200), null=True) art73 = fields.StringField(length=2, choices=("SI",), null=True, xmltag="Art73") def validate_model(self, validation): super().validate_model(validation) has_dati_cassa_previdenziale_ritenuta = False for dcp in self.dati_cassa_previdenziale: if dcp.ritenuta == "SI": has_dati_cassa_previdenziale_ritenuta = True break if has_dati_cassa_previdenziale_ritenuta and not self.dati_ritenuta.has_value(): validation.add_error( self._meta["ritenuta"], "field empty when dati_cassa_previdenziale.ritenuta is SI", code="00415", ) if self.numero is None or not re.search(r"\d", self.numero): validation.add_error( self._meta["numero"], "numero must contain at least one number", code="00425", ) @export class AltriDatiGestionali(models.Model): tipo_dato = fields.StringField(max_length=10) riferimento_testo = fields.StringField(max_length=60, null=True) riferimento_numero = fields.DecimalField(max_length=21, decimals=(2, 8), null=True) riferimento_data = fields.DateField(null=True) @export class CodiceArticolo(models.Model): codice_tipo = fields.StringField(max_length=35) codice_valore = fields.StringField(max_length=35) @export class DettaglioLinee(models.Model): numero_linea = fields.IntegerField(max_length=4) tipo_cessione_prestazione = fields.StringField( length=2, choices=("SC", "PR", "AB", "AC"), null=True ) codice_articolo = fields.ModelListField(CodiceArticolo, null=True) descrizione = fields.StringField(max_length=1000) quantita = fields.DecimalField(max_length=21, decimals=(2, 8), null=True) unita_misura = fields.StringField(max_length=10, null=True) data_inizio_periodo = fields.DateField(null=True) data_fine_periodo = fields.DateField(null=True) prezzo_unitario = fields.DecimalField(max_length=21, decimals=(2, 8)) sconto_maggiorazione = fields.ModelListField(ScontoMaggiorazione, null=True) prezzo_totale = fields.DecimalField(max_length=21, decimals=(2, 8)) aliquota_iva = fields.DecimalField(xmltag="AliquotaIVA", max_length=6) ritenuta = fields.StringField(length=2, choices=("SI",), null=True) natura = fields.StringField( min_length=2, max_length=4, null=True, choices=consts.NATURA_IVA ) riferimento_amministrazione = fields.StringField(max_length=20, null=True) altri_dati_gestionali = fields.ModelListField(AltriDatiGestionali, null=True) def validate_model(self, validation): super().validate_model(validation) if self.quantita is None and self.unita_misura is not None: validation.add_error( self._meta["quantita"], "field must be present when unita_misura is set" ) if self.quantita is not None and self.unita_misura is None: validation.add_error( self._meta["unita_misura"], "field must be present when quantita is set" ) if self.aliquota_iva == 0 and self.natura is None: validation.add_error( self._meta["natura"], "natura non presente a fronte di aliquota_iva pari a zero", code="00400", ) if self.aliquota_iva != 0 and self.natura is not None: validation.add_error( self._meta["natura"], "natura presente a fronte di aliquota_iva diversa da zero", code="00401", ) def autofill_prezzo_totale(self): """ Compute prezzo_totale if it was not set """ if self.prezzo_totale is not None: return if self.quantita is None: self.prezzo_totale = self.prezzo_unitario else: self.prezzo_totale = self.prezzo_unitario * self.quantita @export class DatiRiepilogo(models.Model): aliquota_iva = fields.DecimalField(xmltag="AliquotaIVA", max_length=6) natura = fields.StringField( min_length=2, max_length=4, null=True, choices=consts.NATURA_IVA ) spese_accessorie = fields.DecimalField(max_length=15, null=True) arrotondamento = fields.DecimalField(max_length=21, decimals=(2, 8), null=True) # FIXME: Su questo valore il sistema effettua un controllo per verificare # la correttezza del calcolo; per i dettagli sull’algoritmo di calcolo si # rimanda al file Elenco controlli versione 1.4 presente sul sito # www.fatturapa.gov.it. imponibile_importo = fields.DecimalField(max_length=15) imposta = fields.DecimalField(max_length=15) esigibilita_iva = fields.StringField( xmltag="EsigibilitaIVA", length=1, choices=("I", "D", "S"), null=True ) riferimento_normativo = fields.StringField(max_length=100, null=True) def validate_model(self, validation): super().validate_model(validation) if self.aliquota_iva == 0 and self.natura is None: validation.add_error( self._meta["natura"], "field is empty while aliquota_iva is zero", code="00429", ) if self.aliquota_iva != 0 and self.natura is not None: validation.add_error( self._meta["natura"], "field has value while aliquota_iva is not zero", code="00430", ) @export class DatiBeniServizi(models.Model): dettaglio_linee = fields.ModelListField(DettaglioLinee) dati_riepilogo = fields.ModelListField(DatiRiepilogo) def add_dettaglio_linee(self, **kw): """ Convenience method to add entries to dettaglio_linee, autocomputing numero_linea and prezzo_totale when missing. prezzo_totale is just computed as prezzo_unitario * quantita. For anything more complicated, you need to compute prezzo_totale yourself add pass it explicitly, or better, extend this function and submit a pull request. """ kw.setdefault("numero_linea", len(self.dettaglio_linee) + 1) d = DettaglioLinee(**kw) d.autofill_prezzo_totale() self.dettaglio_linee.append(d) def build_dati_riepilogo(self): """ Convenience method to compute dati_riepilogo. It replaces existing values in dati_riepilogo. It only groups dettaglio_linee by aliquota, sums prezzo_totale to compute imponibile, and applies IVA. For anything more complicated, you need to compute dati_riepilogo yourself, or better, extend this function and submit a pull request. """ from collections import defaultdict # Group by aliquota by_aliquota = defaultdict(list) for linea in self.dettaglio_linee: by_aliquota[(linea.aliquota_iva, linea.natura)].append(linea) self.dati_riepilogo = [] for (aliquota, natura), linee in sorted(by_aliquota.items()): imponibile = sum(linea.prezzo_totale for linea in linee) imposta = imponibile * aliquota / 100 self.dati_riepilogo.append( DatiRiepilogo( aliquota_iva=aliquota, imponibile_importo=imponibile, imposta=imposta, esigibilita_iva="I", natura=natura, ) ) @export class DatiDocumentiCorrelati(models.Model): riferimento_numero_linea = fields.ListField( fields.IntegerField(max_length=4), null=True ) id_documento = fields.StringField(max_length=20) data = fields.DateField(null=True) num_item = fields.StringField(max_length=20, null=True) codice_commessa_convenzione = fields.StringField(max_length=100, null=True) codice_cup = fields.StringField(max_length=15, xmltag="CodiceCUP", null=True) codice_cig = fields.StringField(max_length=15, xmltag="CodiceCIG", null=True) @export class DatiOrdineAcquisto(DatiDocumentiCorrelati): pass @export class DatiContratto(DatiDocumentiCorrelati): pass @export class DatiConvenzione(DatiDocumentiCorrelati): pass @export class DatiRicezione(DatiDocumentiCorrelati): pass @export class DatiFattureCollegate(DatiDocumentiCorrelati): pass @export class DatiAnagraficiVettore(models.Model): id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica numero_licenza_guida = fields.StringField(max_length=20, null=True) @export class IndirizzoResa(IndirizzoType): pass @export class DatiTrasporto(models.Model): dati_anagrafici_vettore = fields.ModelField(DatiAnagraficiVettore, null=True) mezzo_trasporto = fields.StringField(max_length=80, null=True) causale_trasporto = fields.StringField(max_length=100, null=True) numero_colli = fields.IntegerField(max_length=4, null=True) descrizione = fields.StringField(max_length=100, null=True) unita_misura_peso = fields.StringField(max_length=10, null=True) peso_lordo = fields.DecimalField(max_length=7, null=True) peso_netto = fields.DecimalField(max_length=7, null=True) data_ora_ritiro = fields.DateTimeField(null=True) data_inizio_trasporto = fields.DateField(null=True) tipo_resa = fields.StringField(length=3, null=True) indirizzo_resa = fields.ModelField(IndirizzoResa, null=True) data_ora_consegna = fields.DateTimeField(null=True) @export class DatiDDT(models.Model): __xmltag__ = "DatiDDT" numero_ddt = fields.StringField(max_length=20, xmltag="NumeroDDT") data_ddt = fields.DateField(xmltag="DataDDT") riferimento_numero_linea = fields.ListField( fields.IntegerField(max_length=4), null=True ) @export class FatturaPrincipale(models.Model): numero_fattura_principale = fields.StringField(max_length=20) data_fattura_principale = fields.DateField() @export class DatiGenerali(models.Model): dati_generali_documento = DatiGeneraliDocumento dati_ordine_acquisto = fields.ModelListField(DatiOrdineAcquisto, null=True) dati_contratto = fields.ModelListField(DatiContratto, null=True) dati_convenzione = fields.ModelListField(DatiConvenzione, null=True) dati_ricezione = fields.ModelListField(DatiRicezione, null=True) dati_fatture_collegate = fields.ModelListField(DatiFattureCollegate, null=True) # dati_sal = dati_ddt = fields.ModelListField(DatiDDT, null=True) dati_trasporto = fields.ModelField(DatiTrasporto, null=True) fattura_principale = fields.ModelField(FatturaPrincipale, null=True) def validate_model(self, validation): super().validate_model(validation) for idx, dfc in enumerate(self.dati_fatture_collegate): if dfc.data is None: continue if self.dati_generali_documento.data < dfc.data: validation.add_error( ( dfc._meta["data"], self.dati_generali_documento._meta["data"], ), f"dati_generali_documento[{idx}].data" " is earlier than dati_fatture_collegate.data", code="00418", ) @export class DettaglioPagamento(models.Model): beneficiario = fields.StringField(max_length=200, null=True) modalita_pagamento = fields.StringField(length=4, choices=consts.MODALITA_PAGAMENTO) data_riferimento_termini_pagamento = fields.DateField(null=True) giorni_termini_pagamento = fields.IntegerField(max_length=3, null=True) data_scadenza_pagamento = fields.DateField(null=True) importo_pagamento = fields.DecimalField(max_length=15) cod_ufficio_postale = fields.StringField(max_length=20, null=True) cognome_quietanzante = fields.StringField(max_length=60, null=True) nome_quietanzante = fields.StringField(max_length=60, null=True) cf_quietanzante = fields.StringField( max_length=16, null=True, xmltag="CFQuietanzante" ) titolo_quietanzante = fields.StringField(min_length=2, max_length=10, null=True) istituto_finanziario = fields.StringField(max_length=80, null=True) iban = fields.StringField(min_length=15, max_length=34, null=True, xmltag="IBAN") abi = fields.StringField(length=5, null=True, xmltag="ABI") cab = fields.StringField(length=5, null=True, xmltag="CAB") bic = fields.StringField(min_length=8, max_length=11, null=True, xmltag="BIC") sconto_pagamento_anticipato = fields.DecimalField(max_length=15, null=True) data_limite_pagamento_anticipato = fields.DateField(null=True) penalita_pagamenti_ritardati = fields.DecimalField(max_length=15, null=True) data_decorrenza_penale = fields.DateField(null=True) codice_pagamento = fields.StringField(max_length=60, null=True) @export class DatiPagamento(models.Model): condizioni_pagamento = fields.StringField( length=4, choices=("TP01", "TP02", "TP03") ) dettaglio_pagamento = fields.ModelListField(DettaglioPagamento) @export class Allegati(models.Model): nome_attachment = fields.StringField(max_length=60) algoritmo_compressione = fields.StringField(max_length=10, null=True) formato_attachment = fields.StringField(max_length=10, null=True) descrizione_attachment = fields.StringField(max_length=100, null=True) attachment = fields.Base64BinaryField() @export class DatiVeicoli(models.Model): data = fields.DateField() totale_percorso = fields.StringField(max_length=15) @export class FatturaElettronicaBody(models.Model): dati_generali = DatiGenerali dati_beni_servizi = DatiBeniServizi dati_veicoli = models.ModelField(DatiVeicoli, null=True) dati_pagamento = fields.ModelListField(DatiPagamento, null=True) allegati = fields.ModelListField(Allegati, null=True) def build_importo_totale_documento(self): """ Convenience method to compute dati_generali.dati_generali_documento.importo_totale_documento. It replaces an existing value in importo_totale_documento. It only adds imponibile_importo and imposta values from dati_beni_servizi.dati_riepilogo. For anything more complicated, you need to compute importo_totale_documento yourself, or better, extend this function and submit a pull request. """ totale = sum( r.imponibile_importo + r.imposta for r in self.dati_beni_servizi.dati_riepilogo ) self.dati_generali.dati_generali_documento.importo_totale_documento = totale def validate_model(self, validation): super().validate_model(validation) has_ritenute = False has_aliquote_iva = False for dl in self.dati_beni_servizi.dettaglio_linee: if dl.ritenuta == "SI": has_ritenute = True if dl.aliquota_iva is not None: has_aliquote_iva = True if ( has_ritenute and not self.dati_generali.dati_generali_documento.dati_ritenuta.has_value() ): validation.add_error( self.dati_generali.dati_generali_documento._meta["dati_ritenuta"], "field empty while at least one of dati_beni_servizi.dettaglio_linee.ritenuta is SI", code="00411", ) for dcp in self.dati_generali.dati_generali_documento.dati_cassa_previdenziale: if dcp.aliquota_iva is not None: has_aliquote_iva = True if not self.dati_beni_servizi.dati_riepilogo and has_aliquote_iva: validation.add_error( self.dati_beni_servizi._meta["dati_riepilogo"], "dati_riepilogo is empty while there is at least an aliquota_iva" " in dettaglio_linee or dati_cassa_previdenziale", code="00419", ) @export class Fattura(models.Model): __xmlns__ = NS __xmltag__ = "FatturaElettronica" fattura_elettronica_header = FatturaElettronicaHeader fattura_elettronica_body = fields.ModelListField(FatturaElettronicaBody, min_num=1) signature = fields.NotImplementedField(null=True, xmlns=NS_SIG) def __init__(self, *args, **kw): super().__init__(*args, **kw) self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = ( self.get_versione() ) def get_versione(self): return None def get_xmlattrs(self): return {"versione": self.get_versione()} def validate_model(self, validation): super().validate_model(validation) if ( self.get_versione() != self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione ): validation.add_error( self.fattura_elettronica_header.dati_trasmissione._meta[ "formato_trasmissione" ], "formato_trasmissione should be {}".format(self.get_versione()), code="00428", ) def to_xml(self, builder): with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b: with b.override_default_namespace(None) as b1: for name, field in self._meta.items(): field.to_xml(b1, getattr(self, name)) def build_etree(self, lxml=False): """ Build and return an ElementTree with the fattura in XML format """ self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = ( self.get_versione() ) if lxml: from a38.builder import LXMLBuilder builder = LXMLBuilder() else: from a38.builder import Builder builder = Builder() builder.default_namespace = NS self.to_xml(builder) return builder.get_tree() def from_etree(self, el): versione = el.attrib.get("versione", None) if versione is None: raise RuntimeError( "root element {} misses attribute 'versione'".format(el.tag) ) if versione != self.get_versione(): raise RuntimeError( "root element versione is {} instead of {}".format( versione, self.get_versione() ) ) return super().from_etree(el) @export class FatturaPrivati12(Fattura): """ Fattura privati 1.2 """ def get_versione(self): return "FPR12" @export class FatturaPA12(Fattura): """ Fattura PA 1.2 """ def get_versione(self): return "FPA12" @export def auto_from_etree( root: ET.ElementTree, ) -> Union[Fattura, FatturaElettronicaSemplificata]: """ Instantiate a Fattura or FatturaElettronicaSemplificata from a parsed XML """ from .fattura_semplificata import NS10, FatturaElettronicaSemplificata tagname_ordinaria = "{{{}}}FatturaElettronica".format(NS) tagname_semplificata = "{{{}}}FatturaElettronicaSemplificata".format(NS10) versione = root.attrib.get("versione", None) if root.tag == tagname_ordinaria: if versione is None: raise RuntimeError( "root element {} misses attribute 'versione'".format(root.tag) ) if versione == "FPR12": res = FatturaPrivati12() elif versione == "FPA12": res = FatturaPA12() else: raise RuntimeError("unsupported versione {}".format(versione)) elif root.tag == tagname_semplificata: if versione is None: raise RuntimeError( "root element {} misses attribute 'versione'".format(root.tag) ) if versione == "FSM10": res = FatturaElettronicaSemplificata() else: raise RuntimeError("unsupported versione {}".format(versione)) else: raise RuntimeError( "Root element {} is neither {} nor {}".format( root.tag, tagname_ordinaria, tagname_semplificata ) ) res.from_etree(root) return res @export def auto_from_dict( data: Dict[str, Any] ) -> Union[Fattura, FatturaElettronicaSemplificata]: """ Given the equivalent of a Fattura.to_jsonable() data structure, reconstruct the fattura """ from .fattura_semplificata import FatturaElettronicaSemplificata try: formato = data["fattura_elettronica_header"]["dati_trasmissione"][ "formato_trasmissione" ] except KeyError: raise RuntimeError( "fattura_elettronica_header.dati_trasmissione.formato_trasmissione not found in input" ) if formato == "FPR12": return FatturaPrivati12(**data) elif formato == "FPA12": return FatturaPA12(**data) elif formato == "FSM10": return FatturaElettronicaSemplificata(**data) else: raise RuntimeError(f"Unsupported formato_trasmissione: {formato!r}") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/fattura_semplificata.py0000644000177700017770000001436314373374076017510 0ustar00valhallavalhallafrom __future__ import annotations from . import consts, fields, models from .fattura import (Allegati, FullNameMixin, IdFiscaleIVA, IdTrasmittente, IscrizioneREA, Sede, StabileOrganizzazione) NS10 = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.0" class DatiTrasmissione(models.Model): id_trasmittente = IdTrasmittente progressivo_invio = fields.ProgressivoInvioField() formato_trasmissione = fields.StringField(length=5, choices=("FSM10",)) codice_destinatario = fields.StringField(null=True, min_length=6, max_length=7, default="0000000") pec_destinatario = fields.StringField(null=True, min_length=8, max_length=256, xmltag="PECDestinatario") class RappresentanteFiscale(FullNameMixin, models.Model): id_fiscale_iva = IdFiscaleIVA denominazione = fields.StringField(max_length=80, null=True) nome = fields.StringField(max_length=60, null=True) cognome = fields.StringField(max_length=60, null=True) class CedentePrestatore(FullNameMixin, models.Model): id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) denominazione = fields.StringField(max_length=80, null=True) nome = fields.StringField(max_length=60, null=True) cognome = fields.StringField(max_length=60, null=True) sede = Sede stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True) rappresentante_fiscale = models.ModelField(RappresentanteFiscale, null=True) iscrizione_rea = fields.ModelField(IscrizioneREA, null=True) regime_fiscale = fields.StringField( length=4, choices=("RF01", "RF02", "RF04", "RF05", "RF06", "RF07", "RF08", "RF09", "RF10", "RF11", "RF12", "RF13", "RF14", "RF15", "RF16", "RF17", "RF18", "RF19")) class IdentificativiFiscali(models.Model): id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) class AltriDatiIdentificativi(FullNameMixin, models.Model): denominazione = fields.StringField(max_length=80, null=True) nome = fields.StringField(max_length=60, null=True) cognome = fields.StringField(max_length=60, null=True) sede = Sede stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True) rappresentante_fiscale = models.ModelField(RappresentanteFiscale, null=True) class CessionarioCommittente(models.Model): identificativi_fiscali = IdentificativiFiscali altri_dati_identificativi = AltriDatiIdentificativi class FatturaElettronicaHeader(models.Model): dati_trasmissione = DatiTrasmissione cedente_prestatore = CedentePrestatore cessionario_committente = CessionarioCommittente soggetto_emittente = fields.StringField(length=2, choices=("CC", "TZ"), null=True) class DatiGeneraliDocumento(models.Model): tipo_documento = fields.StringField(length=4, choices=("TD07", "TD08", "TD09")) divisa = fields.StringField() data = fields.DateField() numero = fields.StringField(max_length=20) class DatiFatturaRettificata(models.Model): numero_fr = fields.StringField(max_length=20, xmltag="NumeroFR") data_fr = fields.DateField(xmltag="DataFR") elementi_rettificati = fields.StringField(max_length=1000) class DatiGenerali(models.Model): dati_generali_documento = DatiGeneraliDocumento dati_fattura_rettificata = fields.ModelField(DatiFatturaRettificata, null=True) class DatiIVA(models.Model): imposta = fields.DecimalField(max_length=15) aliquota = fields.DecimalField(max_length=6) class DatiBeniServizi(models.Model): descrizione = fields.StringField(max_length=1000) importo = fields.DecimalField(max_length=15) dati_iva = DatiIVA natura = fields.StringField(length=2, null=True, choices=consts.NATURA_IVA) riferimento_normativo = fields.StringField(max_length=100, null=True) class FatturaElettronicaBody(models.Model): dati_generali = DatiGenerali dati_beni_servizi = fields.ModelListField(DatiBeniServizi) allegati = fields.ModelListField(Allegati, null=True) class FatturaElettronicaSemplificata(models.Model): """ Fattura elettronica semplificata """ __xmlns__ = NS10 fattura_elettronica_header = FatturaElettronicaHeader fattura_elettronica_body = fields.ModelListField(FatturaElettronicaBody, min_num=1) def __init__(self, *args, **kw): super().__init__(*args, **kw) self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = self.get_versione() def get_versione(self): return "FSM10" def get_xmlattrs(self): return {"versione": self.get_versione()} def validate_model(self, validation): super().validate_model(validation) if self.get_versione() != self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione: validation.add_error( self.fattura_elettronica_header.dati_trasmissione._meta["formato_trasmissione"], "formato_trasmissione should be {}".format(self.get_versione()), code="00428") def to_xml(self, builder): with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b: with b.override_default_namespace(None) as b1: for name, field in self._meta.items(): field.to_xml(b1, getattr(self, name)) def build_etree(self, lxml=False): """ Build and return an ElementTree with the fattura in XML format """ self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = self.get_versione() if lxml: from a38.builder import LXMLBuilder builder = LXMLBuilder() else: from a38.builder import Builder builder = Builder() builder.default_namespace = NS10 self.to_xml(builder) return builder.get_tree() def from_etree(self, el): versione = el.attrib.get("versione", None) if versione is None: raise RuntimeError("root element {} misses attribute 'versione'".format(el.tag)) if versione != self.get_versione(): raise RuntimeError("root element versione is {} instead of {}".format(versione, self.get_versione())) return super().from_etree(el) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/a38/fields.py0000644000177700017770000005727514547470605014576 0ustar00valhallavalhallafrom __future__ import annotations import base64 import datetime import decimal import logging import re import time from decimal import Decimal from typing import (Any, Generic, List, Optional, Sequence, Tuple, TypeVar, Union) import pytz from dateutil.parser import isoparse from . import builder, validation from .diff import Diff log = logging.getLogger("a38.fields") def to_xmltag(name: str, xmlns: Optional[str] = None): tag = "".join(x.title() for x in name.split("_")) if xmlns is None: return tag return "{" + xmlns + "}" + tag T = TypeVar("T") class Field(Generic[T]): """ Description of a value that can be validated and serialized to XML. It does not contain the value itself. """ # True for fields that can hold a sequence of values multivalue = False def __init__(self, xmlns: Optional[str] = None, xmltag: Optional[str] = None, null: bool = False, default: Optional[T] = None): self.name: Optional[str] = None self.xmlns = xmlns self.xmltag = xmltag self.null = null self.default = default def set_name(self, name: str): """ Set the field name. Used by the Model metaclass to set the field name from the metaclass attribute that defines it """ self.name = name def get_construct_default(self) -> Optional[T]: """ Get the default value for when a field is constructed in the Model constructor, and no value for it has been passed """ return None def has_value(self, value: Optional[T]) -> bool: """ Return True if this value represents a field that has been set """ return value is not None def validate(self, validation: "validation.Validation", value: Any) -> Optional[T]: """ Raise ValidationError(s) if the given value is not valid for this field. Return the cleaned value. """ try: value = self.clean_value(value) except (TypeError, ValueError) as e: validation.add_error(self, str(e)) if not self.null and not self.has_value(value): validation.add_error(self, "missing value") return value def clean_value(self, value: Any) -> Optional[T]: """ Return a cleaned version of the given value """ if value is None: return self.default return value def get_xmltag(self) -> str: """ Return the XML tag to use for this field """ if self.xmltag is not None: if self.xmlns is not None: return "{" + self.xmlns + "}" + self.xmltag else: return self.xmltag if self.name is None: raise RuntimeError("field with uninitialized name") else: return to_xmltag(self.name, self.xmlns) def to_xml(self, builder: "builder.Builder", value: Optional[T]): """ Add this field to an XML tree """ value = self.clean_value(value) if not self.has_value(value): return builder.add(self.get_xmltag(), self.to_str(value)) def to_jsonable(self, value: Optional[T]) -> Any: """ Return a json-able value for this field """ return self.clean_value(value) def to_str(self, value: Optional[T]) -> str: """ Return this value as a string that can be parsed by clean_value """ return str(value) def to_repr(self, value: Optional[T]) -> str: """ Return this value formatted for debugging """ return repr(value) def to_python(self, value: Optional[T], **kw) -> str: """ Return this value as a python expression """ return repr(self.clean_value(value)) def from_etree(self, el): """ Return a value from an ElementTree Element """ return self.clean_value(el.text) def diff(self, res: Diff, first: Optional[T], second: Optional[T]): """ Report to res if there are differences between values first and second """ first = self.clean_value(first) second = self.clean_value(second) has_first = self.has_value(first) has_second = self.has_value(second) if not has_first and not has_second: return elif has_first and not has_second: res.add_only_first(self, first) elif not has_first and has_second: res.add_only_second(self, second) elif first != second: res.add_different(self, first, second) class NotImplementedField(Field[None]): """ Field acting as a placeholder for a part of the specification that is not yet implemented. """ def __init__(self, warn: bool = False, **kw): super().__init__(**kw) self.warn = warn def clean_value(self, value: Any) -> None: if self.warn: log.warning("%s: value received: %r", self.name, value) return None class ChoicesField(Field[T]): def __init__(self, choices: Sequence[T] = None, **kw): super().__init__(**kw) self.choices: Optional[List[Optional[T]]] if choices is not None: self.choices = [self.clean_value(c) for c in choices] else: self.choices = None def validate(self, validation: "validation.Validation", value: Optional[T]): value = super().validate(validation, value) if value is not None and self.choices is not None and value not in self.choices: validation.add_error(self, "{} is not a valid choice for this field".format(self.to_repr(value))) return value class ListField(Field[List[T]]): multivalue = True def __init__(self, field: Field[T], min_num=0, **kw): super().__init__(**kw) self.field = field self.min_num = min_num def set_name(self, name: str): super().set_name(name) self.field.xmltag = self.get_xmltag() def get_construct_default(self): res = [] for i in range(self.min_num): res.append(None) return res def clean_value(self, value): value = super().clean_value(value) if value is None: return value res = [self.field.clean_value(val) for val in value] while len(res) > self.min_num and not self.field.has_value(res[-1]): res.pop() return res def has_value(self, value): if value is None: return False for el in value: if self.field.has_value(el): return True return False def validate(self, validation, value): value = super().validate(validation, value) if not self.has_value(value): return value if len(value) < self.min_num: validation.add_error( self, "list must have at least {} elements, but has only {}".format( self.min_num, len(value))) for idx, val in enumerate(value): with validation.subfield(self.name + "." + str(idx)) as sub: self.field.validate(sub, val) return value def to_xml(self, builder, value): value = self.clean_value(value) if not self.has_value(value): return for val in value: self.field.to_xml(builder, val) def to_jsonable(self, value): value = self.clean_value(value) if not self.has_value(value): return None return [self.field.to_jsonable(val) for val in value] def to_python(self, value, **kw) -> str: value = self.clean_value(value) if not self.has_value(value): return repr(None) return "[" + ", ".join(self.field.to_python(v, **kw) for v in value) + "]" def diff(self, res: Diff, first, second): first = self.clean_value(first) second = self.clean_value(second) has_first = self.has_value(first) has_second = self.has_value(second) if not has_first and not has_second: return elif has_first and not has_second: res.add_only_first(self, first) elif not has_first and has_second: res.add_only_second(self, second) else: for idx, (el_first, el_second) in enumerate(zip(first, second)): with res.subfield(self.name + "." + str(idx)) as subres: if el_first != el_second: self.field.diff(subres, el_first, el_second) if len(first) != len(second): res.add_different_length(self, first, second) def from_etree(self, elements): values = [] for el in elements: values.append(self.field.from_etree(el)) return values class IntegerField(ChoicesField[int]): def __init__(self, max_length=None, **kw): super().__init__(**kw) self.max_length = max_length def clean_value(self, value): value = super().clean_value(value) if value is None: return value return int(value) def validate(self, validation, value): value = super().validate(validation, value) if not self.has_value(value): return value if self.max_length is not None and len(str(value)) > self.max_length: validation.add_error(self, "'{}' should be no more than {} digits long".format(value, self.max_length)) return value class DecimalField(ChoicesField[Decimal]): def __init__( self, max_length: Optional[int] = None, decimals: Union[int, Tuple[int, int]] = 2, **kw): # Set these attributes before calling ChoicesField's __init__, since # that will call clean_value, that needs these fields self.max_length = max_length if isinstance(decimals, int): self.decimals_min = decimals self.decimals_max = decimals else: self.decimals_min, self.decimals_max = decimals super().__init__(**kw) def clean_value(self, value): value = super().clean_value(value) if value is None: return value try: dec_value = Decimal(value) except decimal.InvalidOperation: raise TypeError("{} cannot be converted to Decimal".format(repr(value))) # Enforce fitting into the required range of decimal digits sign, digits, exponent = dec_value.as_tuple() if exponent < 0: # We have decimal digits if -exponent < self.decimals_min: dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_min, rounding=decimal.ROUND_HALF_UP) elif -exponent > self.decimals_max: dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_max, rounding=decimal.ROUND_HALF_UP) else: # No decimal digits if self.decimals_min > 0: dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_min, rounding=decimal.ROUND_HALF_UP) return dec_value def to_str(self, value): if not self.has_value(value): return "None" return str(self.clean_value(value)) def to_jsonable(self, value): """ Return a json-able value for this field """ value = self.clean_value(value) if not self.has_value(value): return None return self.to_str(value) def validate(self, validation, value): value = super().validate(validation, value) if not self.has_value(value): return value if self.max_length is not None: xml_value = self.to_str(value) if len(xml_value) > self.max_length: validation.add_error( self, "'{}' should be no more than {} digits long".format(xml_value, self.max_length)) return value def diff(self, res: Diff, first: Optional[T], second: Optional[T]): """ Report to res if there are differences between values first and second """ first = self.clean_value(first) second = self.clean_value(second) has_first = self.has_value(first) has_second = self.has_value(second) if not has_first and not has_second: return elif has_first and not has_second: res.add_only_first(self, first) elif not has_first and has_second: res.add_only_second(self, second) elif first != second: res.add_different(self, first, second) class StringField(ChoicesField[str]): def __init__(self, length=None, min_length=None, max_length=None, **kw): super().__init__(**kw) if length is not None: if min_length is not None or max_length is not None: raise ValueError("length cannot be used with min_length or max_length") self.min_length = self.max_length = length else: self.min_length = min_length self.max_length = max_length def clean_value(self, value): value = super().clean_value(value) if value is None: return value return str(value) def validate(self, validation, value): value = super().validate(validation, value) if not self.has_value(value): return value if self.min_length is not None and len(value) < self.min_length: validation.add_error(self, "'{}' should be at least {} characters long".format(value, self.min_length)) if self.max_length is not None and len(value) > self.max_length: validation.add_error(self, "'{}' should be no more than {} characters long".format(value, self.max_length)) return value class Base64BinaryField(Field[bytes]): def clean_value(self, value): value = super().clean_value(value) if value is None: return value if isinstance(value, bytes): return value if isinstance(value, str): return base64.b64decode(value) raise TypeError("'{}' is not an instance of str, or bytes".format(repr(value))) def to_jsonable(self, value: Optional[T]) -> Any: """ Return a json-able value for this field """ return self.to_str(self.clean_value(value)) def to_str(self, value: Optional[T]) -> str: """ Return this value as a string that can be parsed by clean_value """ if value is None: return None return base64.b64encode(value).decode("utf8") class DateField(ChoicesField[datetime.date]): re_clean_date = re.compile(r"^\s*(\d{4}-\d{1,2}-\d{1,2})") def clean_value(self, value): value = super().clean_value(value) if value is None: return value if isinstance(value, str): mo = self.re_clean_date.match(value) if not mo: raise ValueError("Date '{}' does not begin with YYYY-mm-dd".format(value)) return datetime.datetime.strptime(mo.group(1), "%Y-%m-%d").date() elif isinstance(value, datetime.datetime): return value.date() elif isinstance(value, datetime.date): return value else: raise TypeError("'{}' is not an instance of str, datetime.date or datetime.datetime".format(repr(value))) def to_jsonable(self, value): """ Return a json-able value for this field """ value = self.clean_value(value) if not self.has_value(value): return None return self.to_str(value) def to_str(self, value): if value is None: return "None" return value.strftime("%Y-%m-%d") class DateTimeField(ChoicesField[datetime.datetime]): tz_rome = pytz.timezone("Europe/Rome") def clean_value(self, value): value = super().clean_value(value) if value is None: return value if isinstance(value, str): res = isoparse(value) if res.tzinfo is None: res = self.tz_rome.localize(res) return res elif isinstance(value, datetime.datetime): if value.tzinfo is None: return self.tz_rome.localize(value) return value elif isinstance(value, datetime.date): return datetime.datetime.combine(value, datetime.time(0, 0, 0, tzinfo=self.tz_rome)) else: raise TypeError("'{}' is not an instance of str, datetime.date or datetime.datetime".format(repr(value))) def to_jsonable(self, value): """ Return a json-able value for this field """ value = self.clean_value(value) if not self.has_value(value): return None return self.to_str(value) def to_python(self, value, **kw): value = self.clean_value(value) if not self.has_value(value): return repr(value) return repr(value.isoformat()) def to_str(self, value): if not self.has_value(value): return "None" return value.isoformat() def to_repr(self, value): if not self.has_value(value): return "None" return value.isoformat() class ProgressivoInvioField(StringField): CHARS = "+-./0123456789=@ABCDEFGHIJKLMNOPQRSTUVWXYZ_" TS_RANGE = 2 ** (54 - 16) SEQUENCE_RANGE = 2 ** 16 last_ts = None sequence = 0 def __init__(self, **kw): kw["max_length"] = 10 super().__init__(**kw) def _encode_b56(self, value, places): res = [] while value > 0: res.append(self.CHARS[value % 43]) value //= 43 return "".join(res[::-1]) def get_construct_default(self): ts = int(time.time()) if self.last_ts is None or self.last_ts != ts: self.sequence = 0 self.last_ts = ts else: self.sequence += 1 if self.sequence > (64 ** 3): raise OverflowError( "Generated more than {} fatture per second, overflowing local counter".format(64 ** 3)) value = (ts << 16) + self.sequence return self._encode_b56(value, 10) class ModelField(Field): """ Field containing the structure from a Model """ def __init__(self, model, **kw): super().__init__(**kw) self.model = model def __str__(self): return "ModelField({})".format(self.model.__name__) __repr__ = __str__ def get_construct_default(self): return self.model() def clean_value(self, value): value = super().clean_value(value) if value is None: return value return self.model.clean_value(value) def has_value(self, value): if value is None: return False return value.has_value() def get_xmltag(self): if self.xmltag is not None: if self.xmlns is not None: return "{" + self.xmlns + "}" + self.xmltag else: return self.xmltag return self.model.get_xmltag() def validate(self, validation, value): value = super().validate(validation, value) if not self.has_value(value): return value with validation.subfield(self.name) as sub: value.validate_fields(sub) value.validate_model(validation) return value def to_xml(self, builder, value): value = self.clean_value(value) if not self.has_value(value): return value.to_xml(builder) def to_jsonable(self, value): value = self.clean_value(value) if not self.has_value(value): return None return value.to_jsonable() def to_python(self, value, **kw) -> str: value = self.clean_value(value) if not self.has_value(value): return repr(None) return value.to_python(**kw) def diff(self, res: Diff, first, second): first = self.clean_value(first) second = self.clean_value(second) has_first = self.has_value(first) has_second = self.has_value(second) if not has_first and not has_second: return elif has_first and not has_second: res.add_only_first(self, first) elif not has_first and has_second: res.add_only_second(self, first) else: with res.subfield(self.name) as subres: first.diff(subres, second) def from_etree(self, el): res = self.model() res.from_etree(el) return res class ModelListField(Field): """ Field containing a list of model instances """ multivalue = True def __init__(self, model, min_num=0, **kw): super().__init__(**kw) self.model = model self.min_num = min_num def get_construct_default(self): res = [] for i in range(self.min_num): res.append(self.model()) return res def clean_value(self, value): value = super().clean_value(value) if value is None: return value res = [self.model.clean_value(val) for val in value] while len(res) > self.min_num and (res[-1] is None or not res[-1].has_value()): res.pop() return res def has_value(self, value): if value is None: return False for el in value: if el.has_value(): return True return False def get_xmltag(self): if self.xmltag is not None: return self.xmltag return self.model.get_xmltag() def validate(self, validation, value): value = super().validate(validation, value) if not self.has_value(value): return value if len(value) < self.min_num: validation.add_error( self, "list must have at least {} elements, but has only {}".format(self.min_num, len(value))) for idx, val in enumerate(value): with validation.subfield(self.name + "." + str(idx)) as sub: val.validate_fields(sub) val.validate_model(validation) return value def to_xml(self, builder, value): value = self.clean_value(value) if not self.has_value(value): return for val in value: val.to_xml(builder) def to_jsonable(self, value): value = self.clean_value(value) if not self.has_value(value): return None return [val.to_jsonable() for val in value] def to_python(self, value, **kw) -> str: value = self.clean_value(value) if not self.has_value(value): return repr(None) return "[" + ", ".join(v.to_python(**kw) for v in value) + "]" def diff(self, res: Diff, first, second): first = self.clean_value(first) second = self.clean_value(second) has_first = self.has_value(first) has_second = self.has_value(second) if not has_first and not has_second: return if has_first and not has_second: res.add_only_first(self, first) elif not has_first and has_second: res.add_only_second(self, second) else: for idx, (el_first, el_second) in enumerate(zip(first, second)): with res.subfield(self.name + "." + str(idx)) as subres: el_first.diff(subres, el_second) if len(first) != len(second): res.add_different_length(self, first, second) def from_etree(self, elements): values = [] for el in elements: value = self.model() value.from_etree(el) values.append(value) return values ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690963938.0 a38-0.1.8/a38/models.py0000644000177700017770000002335214462407742014576 0ustar00valhallavalhallafrom __future__ import annotations from collections import defaultdict from typing import Any, Dict, Optional, Tuple from .fields import Field, ModelField from .validation import Validation class ModelBase: __slots__ = () def __init__(self): pass @classmethod def get_xmltag(cls) -> str: xmltag = getattr(cls, "__xmltag__", None) if xmltag is None: xmltag = cls.__name__ xmlns = getattr(cls, "__xmlns__", None) if xmlns: return "{" + xmlns + "}" + xmltag else: return xmltag def get_xmlattrs(self) -> Dict[str, str]: return {} class ModelMetaclass(type): def __new__(cls, name, bases, dct): _meta = {} # Add fields from subclasses for b in bases: if not issubclass(b, ModelBase): continue b_meta = getattr(b, "_meta", None) if b_meta is None: continue _meta.update(b_meta) # Add fields from the class itself slots = [] for field_name, val in list(dct.items()): if isinstance(val, Field): # Store its description in the Model _meta _meta[field_name] = val val.set_name(field_name) elif isinstance(val, type) and issubclass(val, ModelBase): # Store its description in the Model _meta val = ModelField(val) _meta[field_name] = val val.set_name(field_name) else: # Leave untouched continue # Remove field_name from class variables del dct[field_name] # Add it as a slot in the instance slots.append(field_name) dct["__slots__"] = slots res = super().__new__(cls, name, bases, dct) res._meta = _meta return res class Model(ModelBase, metaclass=ModelMetaclass): """ Declarative description of a data structure that can be validated and serialized to XML. """ __slots__ = () def __init__(self, *args, **kw): super().__init__() for name, value in zip(self._meta.keys(), args): kw[name] = value for name, field in self._meta.items(): value = kw.pop(name, None) if value is None: value = field.get_construct_default() else: value = field.clean_value(value) setattr(self, name, value) def update(self, *args, **kw): """ Set multiple values in the model. Arguments are treated in the same way as in the constructor. Any field not mentioned is left untouched. """ for name, value in zip(self._meta.keys(), args): setattr(self, name, value) for name, value in kw.items(): setattr(self, name, value) def has_value(self): for name, field in self._meta.items(): if field.has_value(getattr(self, name)): return True return False @classmethod def clean_value(cls, value: Any) -> Optional["Model"]: """ Create a model from the given value. Always make a copy even if value is already of the right class, to prevent mutability issues. """ if value is None: return None if isinstance(value, dict): return cls(**value) elif isinstance(value, ModelBase): kw = {} for name, field in cls._meta.items(): kw[name] = getattr(value, name, None) return cls(**kw) else: raise TypeError(f"{cls.__name__}: {value!r} is {type(value).__name__}" " instead of a Model or dict instance") def validate_fields(self, validation: Validation): for name, field in self._meta.items(): field.validate(validation, getattr(self, name)) def validate_model(self, validation: Validation): pass def validate(self, validation: Validation): self.validate_fields(validation) self.validate_model(validation) def to_jsonable(self): res = {} for name, field in self._meta.items(): value = field.to_jsonable(getattr(self, name)) if value is not None: res[name] = value return res def to_python(self, **kw) -> str: args = [] for name, field in self._meta.items(): value = getattr(self, name) if not field.has_value(value): continue args.append(name + "=" + field.to_python(value, **kw)) namespace = kw.get("namespace") if namespace is None: constructor = self.__class__.__module__ + "." + self.__class__.__qualname__ elif namespace is False: constructor = self.__class__.__qualname__ else: constructor = namespace + "." + self.__class__.__qualname__ return "{}({})".format(constructor, ", ".join(args)) def to_xml(self, builder): with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b: for name, field in self._meta.items(): field.to_xml(b, getattr(self, name)) def __setattr__(self, key: str, value: any): field = self._meta.get(key, None) if field is not None: value = field.clean_value(value) super().__setattr__(key, value) def _to_tuple(self) -> Tuple[Any]: return tuple(getattr(self, name) for name in self._meta.keys()) def __eq__(self, other): other = self.clean_value(other) has_self = self.has_value() has_other = other is not None and other.has_value() if not has_self and not has_other: return True if has_self != has_other: return False return self._to_tuple() == other._to_tuple() def __ne__(self, other): other = self.clean_value(other) has_self = self.has_value() has_other = other is not None and other.has_value() if not has_self and not has_other: return False if has_self != has_other: return True return self._to_tuple() != other._to_tuple() def __lt__(self, other): other = self.clean_value(other) has_self = self.has_value() has_other = other is not None and other.has_value() if not has_self and not has_other: return False if has_self and not has_other: return False if not has_self and has_other: return True return self._to_tuple() < other._to_tuple() def __gt__(self, other): other = self.clean_value(other) has_self = self.has_value() has_other = other is not None and other.has_value() if not has_self and not has_other: return False if has_self and not has_other: return True if not has_self and has_other: return False return self._to_tuple() > other._to_tuple() def __le__(self, other): other = self.clean_value(other) has_self = self.has_value() has_other = other is not None and other.has_value() if not has_self and not has_other: return True if has_self and not has_other: return False if not has_self and has_other: return True return self._to_tuple() <= other._to_tuple() def __ge__(self, other): other = self.clean_value(other) has_self = self.has_value() has_other = other is not None and other.has_value() if not has_self and not has_other: return True if has_self and not has_other: return True if not has_self and has_other: return False return self._to_tuple() >= other._to_tuple() def __str__(self): vals = [] for name, field in self._meta.items(): vals.append(name + "=" + field.to_str(getattr(self, name))) return "{}({})".format(self.__class__.__name__, ", ".join(vals)) def __repr__(self): vals = [] for name, field in self._meta.items(): vals.append(name + "=" + field.to_str(getattr(self, name))) return "{}({})".format(self.__class__.__name__, ", ".join(vals)) def from_etree(self, el): if el.tag != self.get_xmltag(): raise RuntimeError("element is {} instead of {}".format(el.tag, self.get_xmltag())) tag_map = {field.get_xmltag(): (name, field) for name, field in self._meta.items()} # Group values by tag by_name = defaultdict(list) for child in el: try: name, field = tag_map[child.tag] except KeyError: raise RuntimeError("found unexpected element {} in {}".format(child.tag, el.tag)) by_name[name].append(child) for name, elements in by_name.items(): field = self._meta[name] if field.multivalue: setattr(self, name, field.from_etree(elements)) elif len(elements) != 1: raise RuntimeError( "found {} {} elements in {} instead of just 1".format( len(elements), child.tag, el.tag)) else: setattr(self, name, field.from_etree(elements[0])) def diff(self, diff, other): has_self = self.has_value() has_other = other.has_value() if not has_self and not has_other: return if has_self != has_other: diff.add(None, self, other) return for name, field in self._meta.items(): first = getattr(self, name) second = getattr(other, name) field.diff(diff, first, second) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/render.py0000644000177700017770000000516214373374076014575 0ustar00valhallavalhallaimport os import subprocess import tempfile from typing import Optional try: import lxml.etree HAVE_LXML = True except ModuleNotFoundError: HAVE_LXML = False if HAVE_LXML: class XSLTTransform: def __init__(self, xslt): parsed_xslt = lxml.etree.parse(xslt) self.xslt = lxml.etree.XSLT(parsed_xslt) def __call__(self, f): """ Return the ElementTree for f rendered as HTML """ tree = f.build_etree(lxml=True) return self.xslt(tree) def _requires_enable_local_file_access(self, wkhtmltopdf: str): """ Check if we need to pass --enable-local-file-access to wkhtmltopdf. See https://github.com/Truelite/python-a38/issues/6 for details """ # We need to specifically use --extended-help, because --help does # not always document --enable-local-file-access verifyLocalAccessToFileOption = subprocess.run( [wkhtmltopdf, "--extended-help"], stdin=subprocess.DEVNULL, text=True, capture_output=True) return "--enable-local-file-access" in verifyLocalAccessToFileOption.stdout def to_pdf(self, wkhtmltopdf: str, f, output_file: Optional[str] = None): """ Render a fattura to PDF using the given wkhtmltopdf command. Returns None if output_file is given, or the binary PDF data if not """ if output_file is None: output_file = "-" html = self(f) # TODO: pass html data as stdin, using '-' as input for # wkhtmltopdf: that currently removes the requirement for # --enable-local-file-access with tempfile.NamedTemporaryFile("wb", suffix=".html", delete=False) as fd: html.write(fd) tempFilename = fd.name try: cmdLine = [wkhtmltopdf, tempFilename, output_file] if self._requires_enable_local_file_access(wkhtmltopdf): cmdLine.insert(1, "--enable-local-file-access") res = subprocess.run(cmdLine, stdin=subprocess.DEVNULL, capture_output=True) if res.returncode != 0: raise RuntimeError( "{0} exited with error {1}: stderr: {2!r}".format( wkhtmltopdf, res.returncode, res.stderr)) if output_file == "-": return res.stdout else: return None finally: os.remove(tempFilename) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/a38/traversal.py0000644000177700017770000000173114547470605015315 0ustar00valhallavalhallafrom contextlib import contextmanager from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from . import fields class Annotation: def __init__(self, prefix: Optional[str], field: "fields.Field"): self.prefix = prefix self.field = field @property def qualified_field(self) -> str: if self.prefix is None: return self.field.name elif self.field.name is None: return self.prefix else: return self.prefix + "." + self.field.name class Traversal: def __init__(self, prefix: Optional[str] = None): self.prefix = prefix def with_prefix(self, prefix: str) -> "Traversal": raise NotImplementedError("Traversal subclasses must implement with_prefix") @contextmanager def subfield(self, name: str): if self.prefix is None: prefix = name else: prefix = self.prefix + "." + name yield self.with_prefix(prefix) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/trustedlist.py0000644000177700017770000002271614373374076015710 0ustar00valhallavalhallaimport base64 import logging import re import subprocess try: from defusedxml import ElementTree as ET except ModuleNotFoundError: import xml.etree.ElementTree as ET from collections import defaultdict from pathlib import Path from typing import Dict from cryptography import x509 from . import fields, models log = logging.getLogger("__name__") NS = "http://uri.etsi.org/02231/v2#" NS_XMLDSIG = "http://www.w3.org/2000/09/xmldsig#" NS_ADDTYPES = "http://uri.etsi.org/02231/v2/additionaltypes#" class OtherInformation(models.Model): __xmlns__ = NS tsl_type = fields.NotImplementedField(xmltag="TSLType", xmlns=NS) scheme_territory = fields.StringField(null=True, xmlns=NS) mime_type = fields.StringField(null=True, xmlns=NS_ADDTYPES) scheme_operator_name = fields.NotImplementedField(xmlns=NS) scheme_type_community_rules = fields.NotImplementedField(xmlns=NS) class AdditionalInformation(models.Model): __xmlns__ = NS other_information = fields.ModelListField(OtherInformation) class OtherTSLPointer(models.Model): __xmlns__ = NS tsl_location = fields.StringField(xmltag="TSLLocation", xmlns=NS) service_digital_identities = fields.NotImplementedField(xmlns=NS) additional_information = AdditionalInformation class PointersToOtherTSL(models.Model): __xmlns__ = NS other_tsl_pointer = fields.ModelListField(OtherTSLPointer) class SchemeInformation(models.Model): __xmlns__ = NS pointers_to_other_tsl = fields.ModelField(PointersToOtherTSL) tsl_version_identifier = fields.NotImplementedField(xmltag="TSLVersionIdentifier", xmlns=NS) tsl_sequence_number = fields.NotImplementedField(xmltag="TSLSequenceNumber", xmlns=NS) tsl_type = fields.NotImplementedField(xmltag="TSLType", xmlns=NS) scheme_operator_name = fields.NotImplementedField(xmlns=NS) scheme_operator_address = fields.NotImplementedField(xmlns=NS) scheme_information_uri = fields.NotImplementedField(xmltag="SchemeInformationURI", xmlns=NS) scheme_name = fields.NotImplementedField(xmlns=NS) status_determination_approach = fields.NotImplementedField(xmlns=NS) scheme_type_community_rules = fields.NotImplementedField(xmlns=NS) scheme_territory = fields.NotImplementedField(xmlns=NS) policy_or_legal_notice = fields.NotImplementedField(xmlns=NS) historical_information_period = fields.NotImplementedField(xmlns=NS) list_issue_date_time = fields.NotImplementedField(xmlns=NS) next_update = fields.NotImplementedField(xmlns=NS) distribution_points = fields.NotImplementedField(xmlns=NS) class TSPInformation(models.Model): __xmlns__ = NS tsp_name = fields.NotImplementedField(xmltag="TSPName", xmlns=NS) tsp_trade_name = fields.NotImplementedField(xmltag="TSPTradeName", xmlns=NS) tsp_address = fields.NotImplementedField(xmltag="TSPAddress", xmlns=NS) tsp_information_url = fields.NotImplementedField(xmltag="TSPInformationURI", xmlns=NS) class DigitalId(models.Model): __xmlns__ = NS x509_subject_name = fields.StringField(xmltag="X509SubjectName", xmlns=NS, null=True) x509_ski = fields.StringField(xmltag="X509SKI", xmlns=NS, null=True) x509_certificate = fields.StringField(xmltag="X509Certificate", xmlns=NS, null=True) class ServiceDigitalIdentity(models.Model): __xmlns__ = NS digital_id = fields.ModelListField(DigitalId) class ServiceInformation(models.Model): __xmlns__ = NS service_type_identifier = fields.StringField(xmlns=NS) service_name = fields.NotImplementedField(xmlns=NS) service_digital_identity = ServiceDigitalIdentity service_status = fields.StringField(xmlns=NS) status_starting_time = fields.NotImplementedField(xmlns=NS) service_information_extensions = fields.NotImplementedField(xmlns=NS) class TSPService(models.Model): __xmlns__ = NS service_information = ServiceInformation service_history = fields.NotImplementedField(xmlns=NS) class TSPServices(models.Model): __xmlns__ = NS tsp_service = fields.ModelListField(TSPService) class TrustServiceProvider(models.Model): __xmlns__ = NS tsp_information = TSPInformation tsp_services = TSPServices class TrustServiceProviderList(models.Model): __xmlns__ = NS trust_service_provider = fields.ModelListField(TrustServiceProvider) class TrustServiceStatusList(models.Model): __xmlns__ = NS scheme_information = SchemeInformation signature = fields.NotImplementedField(xmlns=NS_XMLDSIG) trust_service_provider_list = TrustServiceProviderList def get_tsl_pointer_by_territory(self, territory): for other_tsl_pointer in self.scheme_information.pointers_to_other_tsl.other_tsl_pointer: territory = None for oi in other_tsl_pointer.additional_information.other_information: if oi.scheme_territory is not None: territory = oi.scheme_territory break if territory != "IT": continue return other_tsl_pointer.tsl_location def auto_from_etree(root): expected_tag = "{{{}}}TrustServiceStatusList".format(NS) if root.tag != expected_tag: raise RuntimeError("Root element {} is not {}".format(root.tag, expected_tag)) res = TrustServiceStatusList() res.from_etree(root) return res def load_url(url: str): """ Return a TrustedServiceStatusList instance from the XML downloaded from the given URL """ import requests res = requests.get(url) res.raise_for_status() root = ET.fromstring(res.content) return auto_from_etree(root) def load_certs() -> Dict[str, x509.Certificate]: """ Download trusted list certificates for Italy, parse them and return a dict mapping certificate names good for use as file names to cryptography.x509 certificates """ re_clean_fname = re.compile(r"[^A-Za-z0-9_-]") eu_url = "https://ec.europa.eu/information_society/policy/esignature/trusted-list/tl-mp.xml" log.info("Downloading EU index from %s", eu_url) eu_tl = load_url(eu_url) it_url = eu_tl.get_tsl_pointer_by_territory("IT") log.info("Downloading IT data from %s", it_url) trust_service_status_list = load_url(it_url) by_name = defaultdict(list) for tsp in trust_service_status_list.trust_service_provider_list.trust_service_provider: for tsp_service in tsp.tsp_services.tsp_service: si = tsp_service.service_information if si.service_status not in ( "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/recognisedatnationallevel", "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/granted"): continue if si.service_type_identifier not in ( "http://uri.etsi.org/TrstSvc/Svctype/CA/QC",): continue # print("identifier", si.service_type_identifier) # print("status", si.service_status) cert = [] sn = [] for di in si.service_digital_identity.digital_id: if di.x509_subject_name is not None: sn.append(di.x509_subject_name) # if di.x509_ski is not None: # print(" SKI:", di.x509_ski) if di.x509_certificate is not None: from cryptography import x509 from cryptography.hazmat.backends import default_backend der = base64.b64decode(di.x509_certificate) cert.append(x509.load_der_x509_certificate(der, default_backend())) if len(cert) == 0: raise RuntimeError("{} has no certificates".format(sn)) elif len(cert) > 1: raise RuntimeError("{} has {} certificates".format(sn, len(cert))) else: from cryptography.x509.oid import NameOID cert = cert[0] cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value # print("sn", sn) # print(cert) # print("full cn", cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)) # print("cn", cn) fname = re_clean_fname.sub("_", cn) by_name[fname].append(cert) res = {} for name, certs in by_name.items(): if len(certs) == 1: if name in res: raise RuntimeError("{} already in result".format(name)) res[name] = certs[0] else: for idx, cert in enumerate(certs, start=1): idxname = name + "_a38_{}".format(idx) if idxname in res: raise RuntimeError("{} already in result".format(name)) res[idxname] = cert return res def update_capath(destdir: Path, remove_old=False): from cryptography.hazmat.primitives import serialization certs = load_certs() if destdir.is_dir(): current = set(c.name for c in destdir.iterdir() if c.name.endswith(".crt")) else: current = set() destdir.mkdir(parents=True) for name, cert in certs.items(): fname = name + ".crt" current.discard(fname) pathname = destdir / fname with pathname.open(mode="wb") as fd: fd.write(cert.public_bytes(serialization.Encoding.PEM)) log.info("%s: written", pathname) if remove_old: for fname in current: pathname = destdir / fname pathname.unlink() log.info("%s: removed", pathname) subprocess.run(["openssl", "rehash", destdir], check=True) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38/validation.py0000644000177700017770000000362614373374076015453 0ustar00valhallavalhallafrom typing import List, Optional, Sequence, Union from . import fields from .traversal import Annotation, Traversal class ValidationError(Annotation): def __init__(self, prefix: Optional[str], field: "fields.Field", msg: str, code: str = None): self.prefix = prefix self.field = field self.msg = msg self.code = code def __str__(self): if self.code is not None: return "{}: [{}] {}".format(self.qualified_field, self.code, self.msg) else: return "{}: {}".format(self.qualified_field, self.msg) Fields = Union["fields.Field", Sequence["fields.Field"]] class Validation(Traversal): def __init__(self, prefix: Optional[str] = None, warnings: Optional[List[ValidationError]] = None, errors: Optional[List[ValidationError]] = None): super().__init__(prefix) self.warnings: List[ValidationError] self.errors: List[ValidationError] if warnings is None: self.warnings = [] else: self.warnings = warnings if errors is None: self.errors = [] else: self.errors = errors def with_prefix(self, prefix: str): return Validation(prefix, self.warnings, self.errors) def add_warning(self, field: Fields, msg: str, code: str = None): if isinstance(field, fields.Field): self.warnings.append(ValidationError(self.prefix, field, msg, code)) else: for f in field: self.warnings.append(ValidationError(self.prefix, f, msg, code)) def add_error(self, field: Fields, msg: str, code: str = None): if isinstance(field, fields.Field): self.errors.append(ValidationError(self.prefix, field, msg, code)) else: for f in field: self.errors.append(ValidationError(self.prefix, f, msg, code)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/a38.egg-info/0000755000177700017770000000000014547515512014425 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893258.0 a38-0.1.8/a38.egg-info/PKG-INFO0000644000177700017770000001607314547515512015531 0ustar00valhallavalhallaMetadata-Version: 2.1 Name: a38 Version: 0.1.8 Summary: parse and generate Italian Fattura Elettronica Home-page: https://github.com/Truelite/python-a38/ Author: Enrico Zini Author-email: enrico@truelite.it License: https://www.apache.org/licenses/LICENSE-2.0.html Requires-Python: >=3.11 Description-Content-Type: text/markdown Provides-Extra: cacerts Provides-Extra: formatted_python Provides-Extra: html License-File: LICENSE # Python A38 ![full workflow](https://github.com/Truelite/python-a38/actions/workflows/py.yml/badge.svg) Library to generate Italian Fattura Elettronica from Python. This library implements a declarative data model similar to Django models, that is designed to describe, validate, serialize and parse Italian Fattura Elettronica data. Only part of the specification is implemented, with more added as needs will arise. You are welcome to implement the missing pieces you need and send a pull request: the idea is to have a good, free (as in freedom) library to make billing in Italy with Python easier for everyone. The library can generate various kinds of fatture that pass validation, and can parse all the example XML files distributed by [fatturapa.gov.it](https://www.fatturapa.gov.it/it/lafatturapa/esempi/) ## Dependencies Required: dateutil, pytz, asn1crypto, and the python3 standard library. Optional: * yapf for formatting `a38tool python` output * lxml for rendering to HTML * the wkhtmltopdf command for rendering to PDF * requests for downloading CA certificates for signature verification ## `a38tool` script A simple command line wrapper to the library functions is available as `a38tool`: ```text $ a38tool --help usage: a38tool [-h] [--verbose] [--debug] {json,xml,python,diff,validate,html,pdf,update_capath} ... Handle fattura elettronica files positional arguments: {json,xml,python,diff,validate,html,pdf,update_capath} actions json output a fattura in JSON xml output a fattura in XML python output a fattura as Python code diff show the difference between two fatture validate validate the contents of a fattura html render a Fattura as HTML using a .xslt stylesheet pdf render a Fattura as PDF using a .xslt stylesheet update_capath create/update an openssl CApath with CA certificates that can be used to validate digital signatures optional arguments: -h, --help show this help message and exit --verbose, -v verbose output --debug debug output ``` See [a38tool.md](a38tool.md) for more details. ## Example code ```py import a38 from a38.validation import Validation import datetime import sys cedente_prestatore = a38.CedentePrestatore( a38.DatiAnagraficiCedentePrestatore( a38.IdFiscaleIVA("IT", "01234567890"), codice_fiscale="NTNBLN22C23A123U", anagrafica=a38.Anagrafica(denominazione="Test User"), regime_fiscale="RF01", ), a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"), iscrizione_rea=a38.IscrizioneREA( ufficio="FI", numero_rea="123456", stato_liquidazione="LN", ), contatti=a38.Contatti(email="local_part@pec_domain.it"), ) cessionario_committente = a38.CessionarioCommittente( a38.DatiAnagraficiCessionarioCommittente( a38.IdFiscaleIVA("IT", "76543210987"), anagrafica=a38.Anagrafica(denominazione="A Company SRL"), ), a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"), ) bill_number = 1 f = a38.FatturaPrivati12() f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561") f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF" f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore f.fattura_elettronica_header.cessionario_committente = cessionario_committente body = f.fattura_elettronica_body[0] body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento( tipo_documento="TD01", divisa="EUR", data=datetime.date.today(), numero=bill_number, causale=["Test billing"], ) body.dati_beni_servizi.add_dettaglio_linee( descrizione="Test item", quantita=2, unita_misura="kg", prezzo_unitario="25.50", aliquota_iva="22.00") body.dati_beni_servizi.add_dettaglio_linee( descrizione="Other item", quantita=1, unita_misura="kg", prezzo_unitario="15.50", aliquota_iva="22.00") body.dati_beni_servizi.build_dati_riepilogo() body.build_importo_totale_documento() res = Validation() f.validate(res) if res.warnings: for w in res.warnings: print(str(w), file=sys.stderr) if res.errors: for e in res.errors: print(str(e), file=sys.stderr) filename = "{}{}_{:05d}.xml".format( f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese, f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice, bill_number) tree = f.build_etree() with open(filename, "wb") as out: tree.write(out, encoding="utf-8", xml_declaration=True) ``` # Digital signatures Digital signatures on Firma Elettronica are [CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures. openssl cal verify the signatures, but not yet generate them. A patch to sign with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0) but not yet released as of 2019-02-26. ## Downloading CA certificates CA certificates for validating digital certificates are [distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature). See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati). There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but apparently no way of getting a simple bundle of certificates useable by openssl. `a38tool` has basic features to download and parse CA certificate information, and maintain a CA certificate directory: ``` a38tool update_capath certdir/ --remove-old ``` No particular effort is made to validate the downloaded certificates, besides the standard HTTPS checks performed by the [requests library](http://docs.python-requests.org/en/master/). ## Verifying signed `.p7m` files Once you have a CA certificate directory, verifying signed p7m files is quite straightforward: ``` openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/ ``` # Useful links XSLT stylesheets for displaying fatture: * From [fatturapa.gov.it](https://www.fatturapa.gov.it/), among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html) * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip) # Copyright Copyright 2019-2024 Truelite S.r.l. This software is released under the Apache License 2.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893258.0 a38-0.1.8/a38.egg-info/SOURCES.txt0000644000177700017770000000151414547515512016312 0ustar00valhallavalhalla.gitignore CHANGELOG.md LICENSE MANIFEST.in Makefile README.md a38tool a38tool.md document-a38 download-docs publiccode.yml requirements-devops.txt requirements-lib.txt setup.cfg setup.py test-coverage .github/workflows/py.yml a38/__init__.py a38/builder.py a38/codec.py a38/consts.py a38/crypto.py a38/diff.py a38/fattura.py a38/fattura_semplificata.py a38/fields.py a38/models.py a38/render.py a38/traversal.py a38/trustedlist.py a38/validation.py a38.egg-info/PKG-INFO a38.egg-info/SOURCES.txt a38.egg-info/dependency_links.txt a38.egg-info/requires.txt a38.egg-info/top_level.txt doc/.gitignore doc/README.md stubs/__init__.pyi stubs/dateutil/__init__.pyi stubs/dateutil/parser.pyi tests/test_fattura.py tests/test_fields.py tests/test_models.py tests/test_p7m.py tests/data/dati_trasporto.xml tests/data/test.txt.p7m tests/data/unicode.xml././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893258.0 a38-0.1.8/a38.egg-info/dependency_links.txt0000644000177700017770000000000114547515512020473 0ustar00valhallavalhalla ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893258.0 a38-0.1.8/a38.egg-info/requires.txt0000644000177700017770000000014514547515512017025 0ustar00valhallavalhallaasn1crypto defusedxml python-dateutil pytz [cacerts] requests [formatted_python] yapf [html] lxml ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893258.0 a38-0.1.8/a38.egg-info/top_level.txt0000644000177700017770000000000414547515512017151 0ustar00valhallavalhallaa38 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/a38tool0000755000177700017770000004205014547470605013563 0ustar00valhallavalhalla#!/usr/bin/python3 from __future__ import annotations import argparse import contextlib import fnmatch import logging import os.path import re import shutil import sys from pathlib import Path from typing import TYPE_CHECKING, Optional, IO, Union from a38 import codec, models if TYPE_CHECKING: import fattura from .fattura import Fattura from .fattura_semplificata import FatturaElettronicaSemplificata log = logging.getLogger("a38tool") class Fail(Exception): pass class App: NAME = None def __init__(self, args): self.args = args def load_fattura(self, pathname) -> Union[Fattura, FatturaElettronicaSemplificata]: codecs = codec.Codecs() codec_cls = codecs.codec_from_filename(pathname) return codec_cls().load(pathname) @classmethod def add_subparser(cls, subparsers): name = getattr(cls, "NAME", None) if name is None: name = cls.__name__.lower() parser = subparsers.add_parser(name, help=cls.__doc__.strip()) parser.set_defaults(app=cls) return parser class Diff(App): """ show the difference between two fatture """ NAME = "diff" def __init__(self, args): super().__init__(args) self.first = args.first self.second = args.second @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument("first", help="first input file (.xml or .xml.p7m)") parser.add_argument("second", help="second input file (.xml or .xml.p7m)") return parser def run(self): first = self.load_fattura(self.first) second = self.load_fattura(self.second) from a38.diff import Diff res = Diff() first.diff(res, second) if res.differences: for d in res.differences: print(d) return 1 class Validate(App): """ validate the contents of a fattura """ NAME = "validate" def __init__(self, args): super().__init__(args) self.pathname = args.file @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument("file", help="input file (.xml or .xml.p7m)") return parser def run(self): f = self.load_fattura(self.pathname) from a38.validation import Validation res = Validation() f.validate(res) if res.warnings: for w in res.warnings: print(str(w), file=sys.stderr) if res.errors: for e in res.errors: print(str(e), file=sys.stderr) return 1 class Exporter(App): def __init__(self, args): super().__init__(args) self.files = args.files self.output = args.output self.codec = self.get_codec() def get_codec(self) -> codec.Codec: """ Instantiate the output codec to use for this exporter """ raise NotImplementedError( f"{self.__class__.__name__}.get_codec is not implemented" ) def write(self, f: models.Model, file: Union[IO[str], IO[bytes]]): self.codec.write_file(f, file) @contextlib.contextmanager def open_output(self): if self.output is None: if self.codec.binary: yield sys.stdout.buffer else: yield sys.stdout else: with open(self.output, "wb" if self.codec.binary else "wt") as out: yield out def run(self): with self.open_output() as out: for pathname in self.files: f = self.load_fattura(pathname) self.write(f, out) @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument( "-o", "--output", help="output file (default: standard output)" ) parser.add_argument("files", nargs="+", help="input files (.xml or .xml.p7m)") return parser class ExportJSON(Exporter): """ output a fattura in JSON """ NAME = "json" def get_codec(self) -> codec.Codec: if self.args.indent == "no": indent = None else: try: indent = int(self.args.indent) except ValueError: raise Fail("--indent argument must be an integer on 'no'") return codec.JSON(indent=indent) @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument( "--indent", default="1", help="indentation space (default: 1, use 'no' for all in one line)", ) return parser class ExportYAML(Exporter): """ output a fattura in JSON """ NAME = "yaml" def get_codec(self) -> codec.Codec: return codec.YAML() class ExportXML(Exporter): """ output a fattura in XML """ NAME = "xml" def get_codec(self) -> codec.Codec: return codec.XML() class ExportPython(Exporter): """ output a fattura as Python code """ NAME = "python" def get_codec(self) -> codec.Codec: namespace = self.args.namespace if namespace == "": namespace = False return codec.Python(namespace=namespace, unformatted=self.args.unformatted) @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument( "--namespace", default=None, help="namespace to use for the model classes (default: the module fully qualified name)", ) parser.add_argument( "--unformatted", action="store_true", help="disable code formatting, outputting a single-line statement", ) return parser class Edit(App): """ Open a fattura for modification in a text editor """ def __init__(self, args): super().__init__(args) if self.args.style == "yaml": self.edit_codec = codec.YAML() elif self.args.style == "python": self.edit_codec = codec.Python(loadable=True) else: raise Fail(f"Unsupported edit style {self.args.style!r}") def write_out(self, f): """ Write a fattura, as much as possible over the file being edited """ codecs = codec.Codecs() codec_cls = codecs.codec_from_filename(self.args.file) if codec_cls == codec.P7M: with open(self.args.file[:-4], "wb") as fd: codec_cls().write_file(f, fd) elif codec_cls.binary: with open(self.args.file, "wb") as fd: codec_cls().write_file(f, fd) else: with open(self.args.file, "wt") as fd: codec_cls().write_file(f, fd) def run(self): f = self.load_fattura(self.args.file) f1 = self.edit_codec.interactive_edit(f) if f1 is not None and f != f1: self.write_out(f1) @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument( "-s", "--style", default="yaml", help="editable representation to use, one of 'yaml' or 'python'. Default: $(default)s", ) parser.add_argument("file", help="file to edit") return parser class Renderer(App): """ Base class for CLI commands that render a Fattura """ def __init__(self, args): from a38.render import HAVE_LXML if not HAVE_LXML: raise Fail("python3-lxml is needed for XSLT based rendering") super().__init__(args) self.stylesheet = args.stylesheet self.files = args.files self.output = args.output self.force = args.force from a38.render import XSLTTransform self.transform = XSLTTransform(self.stylesheet) def render(self, f, output: str): """ Render the Fattura to the given destination file """ raise NotImplementedError( self.__class__.__name__ + ".render is not implemented" ) def run(self): for pathname in self.files: dirname = os.path.normpath(os.path.dirname(pathname)) basename = os.path.basename(pathname) basename, ext = os.path.splitext(basename) output = self.output.format(dirname=dirname, basename=basename, ext=ext) if not self.force and os.path.exists(output): log.warning( "%s: output file %s already exists: skipped", pathname, output ) else: log.info("%s: writing %s", pathname, output) f = self.load_fattura(pathname) self.render(f, output) @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument( "-f", "--force", action="store_true", help="overwrite existing output files" ) default_output = "{dirname}/{basename}{ext}." + cls.NAME parser.add_argument( "-o", "--output", default=default_output, help="output file; use {dirname} for the source file path," " {basename} for the source file name" " (default: '" + default_output + "'", ) parser.add_argument( "stylesheet", help=".xsl/.xslt stylesheet file to use for rendering" ) parser.add_argument("files", nargs="+", help="input files (.xml or .xml.p7m)") return parser class RenderHTML(Renderer): """ render a Fattura as HTML using a .xslt stylesheet """ NAME = "html" def render(self, f, output): html = self.transform(f) html.write(output) class RenderPDF(Renderer): """ render a Fattura as PDF using a .xslt stylesheet """ NAME = "pdf" def __init__(self, args): super().__init__(args) self.wkhtmltopdf = shutil.which("wkhtmltopdf") if self.wkhtmltopdf is None: raise Fail("wkhtmltopdf is needed for PDF rendering") def render(self, f, output: str): self.transform.to_pdf(self.wkhtmltopdf, f, output) class UpdateCAPath(App): """ create/update an openssl CApath with CA certificates that can be used to validate digital signatures """ NAME = "update_capath" def __init__(self, args): super().__init__(args) self.destdir = Path(args.destdir) self.remove_old = args.remove_old @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument("destdir", help="CA certificate directory to update") parser.add_argument( "--remove-old", action="store_true", help="remove old certificates" ) return parser def run(self): from a38 import trustedlist as tl tl.update_capath(self.destdir, remove_old=self.remove_old) class Allegati(App): """ Show the attachments in the fattura """ def __init__(self, args: argparse.Namespace) -> None: super().__init__(args) self.pathname = args.file self.ids: set[int] = set() self.globs: list[re.Pattern] = [] self.has_filter = False for pattern in self.args.attachments: self.has_filter = True if pattern.isdigit(): self.ids.add(int(pattern)) elif pattern.startswith("^"): self.globs.append(re.compile(pattern)) else: self.globs.append(re.compile(fnmatch.translate(pattern))) @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) parser.add_argument( "--extract", "-x", action="store_true", help="extract selected attachments" ) parser.add_argument( "--json", action="store_true", help="show attachments in json format" ) parser.add_argument( "--yaml", action="store_true", help="show attachments in yaml format" ) parser.add_argument( "--output", "-o", action="store", help="destination file name (-o file) or directory (-o dir/)", ) parser.add_argument("file", help="input file (.xml or .xml.p7m)") parser.add_argument( "attachments", nargs="*", help="IDs or names of attachments to extract. Shell-like wildcards allowed, or regexps if starting with ^", ) return parser def match_allegato(self, index: int, allegato: fattura.Allegati) -> bool: """ Check if the given allegato matches the attachments patterns """ if not self.has_filter: return True for id in self.ids: if index == id: return True for regex in self.globs: if regex.match(allegato.nome_attachment): return True return False def print_allegato(self, index: int, allegato: fattura.Allegati) -> None: formato = allegato.formato_attachment or "-" print(f"{index:02d}: {formato} {allegato.nome_attachment}") if allegato.descrizione_attachment: print(f" {allegato.descrizione_attachment}") def run(self): f = self.load_fattura(self.pathname) selected: list[tuple[int, fattura.Allegati]] = [] index = 1 for body in f.fattura_elettronica_body: for allegato in body.allegati: if self.match_allegato(index, allegato): selected.append((index, allegato)) index += 1 if self.args.json or self.args.yaml: output = [] for index, allegato in selected: jsonable = {"index": index} jsonable.update(allegato.to_jsonable()) jsonable.pop("attachment", None) output.append(jsonable) if self.args.json: import json json.dump(output, sys.stdout, indent=2) print() else: import yaml yaml.dump( output, stream=sys.stdout, default_flow_style=False, sort_keys=False, allow_unicode=True, explicit_start=True, Dumper=yaml.CDumper, ) elif self.args.extract: destname: Optional[str] destdir: str if self.args.output: if os.path.isdir(self.args.output) or self.args.output.endswith(os.sep): destname = None destdir = self.args.output else: destname = self.args.output destdir = "." else: destname = None destdir = "." if destname is not None and len(selected) > 1: raise Fail( "there are multiple attachment to save, and--output points to a single file name" ) os.makedirs(destdir, exist_ok=True) for index, allegato in selected: if destname is None: destname = os.path.basename(allegato.nome_attachment) dest = os.path.join(destdir, destname) log.info("Extracting %s to %s", allegato.nome_attachment, dest) with open(dest, "wb") as fd: fd.write(allegato.attachment) else: for index, allegato in selected: self.print_allegato(index, allegato) def main(): parser = argparse.ArgumentParser(description="Handle fattura elettronica files") parser.add_argument("--verbose", "-v", action="store_true", help="verbose output") parser.add_argument("--debug", action="store_true", help="debug output") subparsers = parser.add_subparsers(help="actions", required=True) subparsers.dest = "command" ExportJSON.add_subparser(subparsers) ExportYAML.add_subparser(subparsers) ExportXML.add_subparser(subparsers) ExportPython.add_subparser(subparsers) Edit.add_subparser(subparsers) Diff.add_subparser(subparsers) Validate.add_subparser(subparsers) RenderHTML.add_subparser(subparsers) RenderPDF.add_subparser(subparsers) UpdateCAPath.add_subparser(subparsers) Allegati.add_subparser(subparsers) args = parser.parse_args() log_format = "%(levelname)s %(message)s" level = logging.WARN if args.debug: level = logging.DEBUG elif args.verbose: level = logging.INFO logging.basicConfig(level=level, stream=sys.stderr, format=log_format) app = args.app(args) res = app.run() if isinstance(res, int): sys.exit(res) if __name__ == "__main__": try: main() except Fail as e: print(e, file=sys.stderr) sys.exit(1) except Exception: log.exception("uncaught exception") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/a38tool.md0000644000177700017770000001376514373374076014174 0ustar00valhallavalhalla# `a38tool` General command line help: ```text $ a38tool --help usage: a38tool [-h] [--verbose] [--debug] {json,xml,python,diff,validate,html,pdf,update_capath} ... Handle fattura elettronica files positional arguments: {json,xml,python,diff,validate,html,pdf,update_capath} actions json output a fattura in JSON xml output a fattura in XML python output a fattura as Python code diff show the difference between two fatture validate validate the contents of a fattura html render a Fattura as HTML using a .xslt stylesheet pdf render a Fattura as PDF using a .xslt stylesheet update_capath create/update an openssl CApath with CA certificates that can be used to validate digital signatures optional arguments: -h, --help show this help message and exit --verbose, -v verbose output --debug debug output ``` ### Difference between two fatture ```text $ a38tool diff --help usage: a38tool diff [-h] first second positional arguments: first first input file (.xml or .xml.p7m) second second input file (.xml or .xml.p7m) optional arguments: -h, --help show this help message and exit ``` Example: ```text $ a38tool diff doc/IT01234567890_FPR01.xml doc/IT01234567890_FPR02.xml fattura_elettronica_header.dati_trasmissione.codice_destinatario: first: ABC1234, second: 0000000 fattura_elettronica_header.dati_trasmissione.pec_destinatario: first is not set fattura_elettronica_header.cedente_prestatore.dati_anagrafici.regime_fiscale: first: RF19, second: RF01 fattura_elettronica_header.cessionario_committente.dati_anagrafici.anagrafica.denominazione: first: DITTA BETA, second: … fattura_elettronica_body.0.dati_generali.dati_contratto: second is not set fattura_elettronica_body.0.dati_beni_servizi.dettaglio_linee.0.descrizione: first: DESCRIZIONE DELLA FORNITURA, second: … … $ echo $? 1 ``` ### Validate a fattura ```text $ a38tool validate --help usage: a38tool validate [-h] file positional arguments: file input file (.xml or .xml.p7m) optional arguments: -h, --help show this help message and exit ``` Example: ```text $ a38tool validate doc/IT01234567890_FPR01.xml fattura_elettronica_body.0.dati_beni_servizi.unita_misura: field must be present when quantita is set $ echo $? 1 ``` ### Convert a fattura to JSON ```text $ a38tool json --help usage: a38tool json [-h] [-o OUTPUT] [--indent INDENT] files [files ...] positional arguments: files input files (.xml or .xml.p7m) optional arguments: -h, --help show this help message and exit -o OUTPUT, --output OUTPUT output file (default: standard output) --indent INDENT indentation space (default: 1, use 'no' for all in one line) ``` Example: ```text $ a38tool json doc/IT01234567890_FPR02.xml { "fattura_elettronica_header": { "dati_trasmissione": { "id_trasmittente": { "id_paese": "IT", "id_codice": "01234567890" … ``` Use `--indent=no` to output a json per line, making it easy to separate reparse a group of JSON fatture: ```text $ a38tool json --indent=no doc/*.xml {"fattura_elettronica_header": {"dati_tr… {"fattura_elettronica_header": {"dati_tr… {"fattura_elettronica_header": {"dati_tr… … ``` ### Extract XML from a `.p7m` signed fattura ```text $ a38tool xml --help usage: a38tool xml [-h] [-o OUTPUT] files [files ...] positional arguments: files input files (.xml or .xml.p7m) optional arguments: -h, --help show this help message and exit -o OUTPUT, --output OUTPUT output file (default: standard output) ``` ### Generate Python code You can convert a fattura to Python code: this is a quick way to start writing a software that generates fatture similar to an existing one. ```text $ a38tool python --help usage: a38tool python [-h] [-o OUTPUT] [--namespace NAMESPACE] [--unformatted] files [files ...] positional arguments: files input files (.xml or .xml.p7m) optional arguments: -h, --help show this help message and exit -o OUTPUT, --output OUTPUT output file (default: standard output) --namespace NAMESPACE namespace to use for the model classes (default: the module fully qualified name) --unformatted disable code formatting, outputting a single-line statement ``` Example: ```text $ a38tool python doc/IT01234567890_FPR02.xml a38.fattura.FatturaPrivati12( fattura_elettronica_header=a38.fattura.FatturaElettronicaHeader( dati_trasmissione=a38.fattura.DatiTrasmissione( id_trasmittente=a38.fattura.IdTrasmittente( id_paese='IT', id_codice='01234567890'), progressivo_invio='00001', … ``` ### Render to HTML or PDF You can use a .xslt file to render a fattura to HTML or PDF. ```text $ a38tool html --help usage: a38tool html [-h] [-f] [-o OUTPUT] stylesheet files [files ...] positional arguments: stylesheet .xsl/.xslt stylesheet file to use for rendering files input files (.xml or .xml.p7m) optional arguments: -h, --help show this help message and exit -f, --force overwrite existing output files -o OUTPUT, --output OUTPUT output file; use {dirname} for the source file path, {basename} for the source file name (default: '{dirname}/{basename}{ext}.html' ``` Example: ```text $ a38tool -v html -f doc/fatturaordinaria_v1.2.1.xsl doc/IT01234567890_FPR02.xml INFO doc/IT01234567890_FPR02.xml: writing doc/IT01234567890_FPR02.xml.html ``` ```text $ a38tool -v pdf -f doc/fatturaordinaria_v1.2.1.xsl doc/IT01234567890_FPR02.xml INFO doc/IT01234567890_FPR02.xml: writing doc/IT01234567890_FPR02.xml.pdf ``` ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/doc/0000755000177700017770000000000014547515512013105 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/doc/.gitignore0000644000177700017770000000006314373374076015101 0ustar00valhallavalhalla/IT01234567890_FP*.xml /*.pdf /*.xls /*.xsd /*.xsl ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/doc/README.md0000644000177700017770000000146614373374076014400 0ustar00valhallavalhallaRun the `download-docs` script to populate this directory with official documentation. Note that the URLS may change, and the script may need to be updated from time to time. Normativa: Formato: Assocons validator: Agenzia delle Entrate validator: # TODO Get documentation from (it covers Fattura Elettronica Semplificata) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/document-a380000755000177700017770000000630414373374076014505 0ustar00valhallavalhalla#!/usr/bin/python3 import argparse import logging import subprocess import os import re import sys log = logging.getLogger("document-a38") class Fail(Exception): pass def sample_output(cmd, max_lines=None, max_line_length=None, returncode=0): res = subprocess.run("./" + cmd, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) if res.returncode != returncode: raise RuntimeError(f"{cmd} return code is {res.returncode} instead of {returncode}") lines = [] for idx, line in enumerate(res.stdout.splitlines()): if max_lines is not None and idx >= max_lines: lines.append("…") break if max_line_length is not None and len(line) > max_line_length: lines.append(line[:max_line_length] + "…") else: lines.append(line) return lines def generate_output(mo): cmd = mo.group("cmd") output = mo.group("output") returncode = mo.group("result") if returncode is not None: returncode = int(returncode) else: returncode = 0 # Autodetect max_lines and max_line_length lines = output.splitlines() max_lines = None if lines[-1] == "…": max_lines = len(lines) - 1 max_line_length = None for l in lines: if len(l) > 1 and l[-1] == "…": max_line_length = len(l) - 1 break lines = [ "```text", f"$ {cmd}", ] lines.extend(sample_output(cmd, max_lines, max_line_length, returncode)) if returncode != 0: lines.append("$ echo $?") lines.append(str(returncode)) lines.append("```") return "\n".join(lines) + "\n" def process_md(fname): with open(fname, "rt") as fd: content = fd.read() # print(re.search( # r"```text\s*\n" # r"(?P.+?)" # r"```" # , content, re.S)) new_content = re.sub( r"```text[ \t]*\n" r"\$ (?Pa38tool [^\n]+)\n" r"(?P.+?)" r"(?:\$ echo \$\?\s*\n(?P\d+)\s*\n)?" r"```[ \t]*\n", generate_output, content, flags=re.S) if new_content == content: return False with open(fname, "wt") as fd: fd.write(new_content) return True def main(): parser = argparse.ArgumentParser(description="Update a38tool examples in markdown documentation") parser.add_argument("--verbose", "-v", action="store_true", help="verbose output") parser.add_argument("--debug", action="store_true", help="debug output") args = parser.parse_args() log_format = "%(asctime)-15s %(levelname)s %(message)s" level = logging.WARN if args.debug: level = logging.DEBUG elif args.verbose: level = logging.INFO logging.basicConfig(level=level, stream=sys.stderr, format=log_format) for fn in os.listdir("."): if not fn.endswith(".md"): continue if process_md(fn): log.warning("%s: updated", fn) else: log.info("%s: unchanged", fn) if __name__ == "__main__": try: main() except Fail as e: print(e, file=sys.stderr) sys.exit(1) except Exception: log.exception("uncaught exception") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/download-docs0000755000177700017770000000573314373374076015040 0ustar00valhallavalhalla#!/usr/bin/python3 import argparse import logging import sys import requests import re import urllib.parse import os from lxml import etree as ET log = logging.getLogger("download_docs") DOCS = [ r"/Schema_del_file_xml.+\.xsd$", r"/Specifiche_tecniche.+\.pdf$", r"/Rappresentazione_tabellare_del_tracciato.+\.(?:pdf|xls)$", r"/fatturaPA.+\.xsl$", r"/fatturaordinaria.+\.xsl$" r"/changelog_formato\.pdf$", r"/Suggerimenti_Compilazione.+\.pdf$", r"/fatturapa.+\.xsl$", r"/fatturaordinaria.+\.xsl$", r"/Elenco_Controlli.+\.pdf$", ] EXAMPLES = [ r"/IT01234567890_FP.+\.xml", ] def get_urls(index_url): index = requests.get(index_url) parser = ET.XMLParser(recover=True) root = ET.fromstring(index.text, parser) re_docs = [re.compile(r) for r in DOCS] re_examples = [re.compile(r) for r in EXAMPLES] links = [] for a in root.iter("a"): href = a.attrib.get("href") if href is None: continue # There seem to be various wrong links to this file, so we ignore # them if "IT01234567890_11111" in href: continue links.append(href) for l in links: for r in re_docs: if r.search(l): yield {"type": "doc", "href": l} for r in re_examples: if r.search(l): yield {"type": "example", "href": l} def download(index_url): for el in get_urls(index_url): url = urllib.parse.urljoin(index_url, el["href"]) parsed = urllib.parse.urlparse(url) filename = os.path.basename(parsed.path) if el["type"] == "doc": dest = os.path.join("doc", filename) elif el["type"] == "example": dest = os.path.join("doc", filename) if os.path.exists(dest): log.info("%s: already downloaded", dest) continue res = requests.get(url, stream=True) with open(dest, 'wb') as fd: for chunk in res.iter_content(chunk_size=128): fd.write(chunk) log.info("%s: downloading", dest) class Fail(Exception): pass def main(): parser = argparse.ArgumentParser(description="download documents and examples from www.fatturapa.gov.it") parser.add_argument("--verbose", "-v", action="store_true", help="verbose output") parser.add_argument("--debug", action="store_true", help="debug output") args = parser.parse_args() log_format = "%(asctime)-15s %(levelname)s %(message)s" level = logging.WARN if args.debug: level = logging.DEBUG elif args.verbose: level = logging.INFO logging.basicConfig(level=level, stream=sys.stderr, format=log_format) download("https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/") if __name__ == "__main__": try: main() except Fail as e: print(e, file=sys.stderr) sys.exit(1) except Exception: log.exception("uncaught exception") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893224.0 a38-0.1.8/publiccode.yml0000644000177700017770000000355314547515450015203 0ustar00valhallavalhalla# This repository adheres to the publiccode.yml standard by including this # metadata file that makes public software easily discoverable. # More info at https://github.com/italia/publiccode.yml publiccodeYmlVersion: '0.2' name: A38 url: 'https://github.com/Truelite/python-a38.git' softwareVersion: 0.1.8 releaseDate: '2024-01-10' inputTypes: - text/xml outputTypes: - text/html - text/xml - text/x-python - application/pdf - application/json - application/x-x509-ca-cert platforms: - linux - windows - ios categories: - billing-and-invoicing developmentStatus: beta softwareType: library dependsOn: open: - name: Python versionMin: '3.11' optional: false maintenance: type: internal contacts: - name: Truelite srl email: a38@truelite.it legal: license: Apache-2.0 mainCopyrightOwner: Truelite srl repoOwner: Truelite srl intendedAudience: countries: - it localisation: localisationReady: false availableLanguages: - en description: en: shortDescription: | parse and generate Italian Fattura Elettronica longDescription: > This library implements a declarative data model similar to Django models, that is designed to describe, validate, serialize and parse Italian Fattura Elettronica data. Only part of the specification is implemented, with more added as needs will arise. Anyone is welcome to implement the missing pieces they need and send a pull request: the idea is to have a good, free (as in freedom) library to make billing in Italy with Python easier for everyone. The library can generate various kinds of fatture that pass validation, and can parse all the example XML files distributed by fatturapa.gov.it features: - | Implement the Italian Fattura Elettronica in a declarative data model genericName: Library ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/requirements-devops.txt0000644000177700017770000000003614373374076017126 0ustar00valhallavalhallaisort autoflake flake8 bandit ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/requirements-lib.txt0000644000177700017770000000005314373374076016373 0ustar00valhallavalhallapython-dateutil pytz asn1crypto defusedxml ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3254445 a38-0.1.8/setup.cfg0000644000177700017770000000023714547515512014163 0ustar00valhallavalhalla[mypy] namespace_packages = True mypy_path = stubs [flake8] max-line-length = 120 [options] python_requires = >= 3.11 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704893210.0 a38-0.1.8/setup.py0000755000177700017770000000171714547515432014064 0ustar00valhallavalhalla#!/usr/bin/env python3 from setuptools import setup with open("README.md", "r") as fp: long_description = fp.read() def parse_requirements(filename): line_iter = (line.strip() for line in open(filename)) return [line for line in line_iter if line and not line.startswith("#")] setup( name="a38", version="0.1.8", description="parse and generate Italian Fattura Elettronica", long_description=long_description, long_description_content_type='text/markdown', author="Enrico Zini", author_email="enrico@truelite.it", url="https://github.com/Truelite/python-a38/", license="https://www.apache.org/licenses/LICENSE-2.0.html", packages=["a38"], scripts=["a38tool"], install_requires=parse_requirements("requirements-lib.txt"), test_requires=parse_requirements("requirements-lib.txt"), extras_require={ "formatted_python": ["yapf"], "html": ["lxml"], "cacerts": ["requests"], }, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/stubs/0000755000177700017770000000000014547515512013500 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/stubs/__init__.pyi0000644000177700017770000000000014373374076015755 0ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/stubs/dateutil/0000755000177700017770000000000014547515512015313 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/stubs/dateutil/__init__.pyi0000644000177700017770000000000014373374076017570 0ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/stubs/dateutil/parser.pyi0000644000177700017770000000010314373374076017331 0ustar00valhallavalhallaimport datetime def isoparse(arg: str) -> datetime.datetime: ... ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/test-coverage0000755000177700017770000000020514373374076015040 0ustar00valhallavalhalla#!/bin/sh set -u if [ $# -eq 0 ] then set -x eatmydata nose2-3 -C --coverage-report html else set -x eatmydata nose2-3 "$@" fi ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3214443 a38-0.1.8/tests/0000755000177700017770000000000014547515512013502 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1704893258.3254445 a38-0.1.8/tests/data/0000755000177700017770000000000014547515512014413 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/tests/data/dati_trasporto.xml0000644000177700017770000001402414547470605020177 0ustar00valhallavalhalla IT 01234567890 00000 FPR12 FUFUFUF local_part@domain.it IT 09876543210 Test denominazione RF01 test Address, 1 12345 Test Comune BO IT IT 01234567891 Test Denominazione 2 test Address, 2 54321 Test Comune 1 BO IT TD01 EUR 2019-01-01 1 123.45 Test Causale RT04 70.00 1.00 ZO RT02 805.00 11.50 Q 1 a1 2018-12-31 1 2 a1 2018-12-31 2 3 a1 2018-12-31 3 4 a1 2018-12-31 4 5 a1 2018-12-31 5 123 456 2019-01-02 1 2 3 4 5 AAA 1 Linea 1 1 NR 1 1 22.00 2 Linea 2 1 NR 1 1 22.00 3 Linea 3 1 NR 1 1 22.00 4 Linea 4 1 NR 1 1 22.00 5 Linea 5 1 NR 1 1 22.00 22.00 5 1.1 I ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690963938.0 a38-0.1.8/tests/data/test.txt.p7m0000644000177700017770000000451514462407742016643 0ustar00valhallavalhalla0 I *H  :0 61 0  `He0P *H CAThis is only a test payload. Questo è solo un payload di test. 00Р!#99j\t)0  *H  0l1 0 UIT10U ArubaPEC S.p.A.1!0U Certification AuthorityC1 0U ArubaPEC S.p.A. NG CA 30 210507000000Z 240506235959Z0w1 0 UIT10U Zini Enrico10UTINIT-ZNINRC76E03A785Z10 U* Enrico1 0 U Zini10U.226106260"0  *H 0 $>e)RKɘh@rh])``ڛ&4K2m&[P~ [ '.&LƤ"kɹ!B+_HL-yzCF\`la?,uq? &v7rJQj8ږ*/p} Ilۯ|Jdd)_ A3p^n!֑lP_)j9{iZ+u+H D~1}}|Փy0u0U@0Uo;w8C >x0OU H0F0< +-0-0++https://ca.arubapec.it/cps.html0+L0XUQ0O0MKIGhttp://crl.arubapec.it/ArubaPECSpACertificationAuthorityC/LatestCRL.crl0+00F0 F0F0F00>8https://www.pec.it/repository/arubapec-qualif-pds-en.pdfen0>8https://www.pec.it/repository/arubapec-qualif-pds-it.pdfit0 U0enrico@enricozini.org0U#0E5_)J/}0j+^0\05+0)http://cacert.pec.it/certs/AP_NG_CA_3.cer0#+0http://ocsp.arubapec.it0(U !00+ 119760503080001Z0  *H  V3>zO3sg%_ &ž_ 0'(^ߛᘤPnlHMWܟFH?NÀV|ߤ2Q) z@p\Cn/k(W|kmD_o"ў>E7 0 rBJq<ߨaFOlj_6u5~Nnv-il2*Gh'i^KY]⑩x0ۇ#B|ifz^6^/au1000l1 0 UIT10U ArubaPEC S.p.A.1!0U Certification AuthorityC1 0U ArubaPEC S.p.A. NG CA 3!#99j\t)0  `He.0 *H  1  *H 0 *H  1 230801135517Z0/ *H  1"  IT10293847561BAEIE+F8OFPR12AAAAAAIT01234567890Astérix le GauloisRF01IT76543210987جعفر محمد بن موسی خوارزمیTD01EUR2019-02-14181.13🅲🅰🆄🆂🅰🅻🅴1🎂🥮🍰🧁2.00kg25.5051.0022.00 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/tests/test_fattura.py0000644000177700017770000003605614547470605016576 0ustar00valhallavalhallaimport datetime import io import tempfile from decimal import Decimal from unittest import SkipTest, TestCase import a38 from a38 import codec, validation class TestFatturaMixin: def assert_validates(self, value, warnings=[], errors=[]): val = validation.Validation() value.validate(val) self.assertEqual([str(x) for x in val.warnings], warnings) self.assertEqual([str(x) for x in val.errors], errors) class TestAnagrafica(TestFatturaMixin, TestCase): def test_validation(self): a = a38.Anagrafica() self.assert_validates(a, errors=[ 'nome: nome and cognome, or denominazione, must be set', 'cognome: nome and cognome, or denominazione, must be set', 'denominazione: nome and cognome, or denominazione, must be set', ]) a.nome = "Test" self.assert_validates(a, errors=[ "cognome: nome and cognome must both be set if denominazione is empty", ]) a.cognome = "Test1" self.assert_validates(a) a.nome = None self.assert_validates(a, errors=[ "nome: nome and cognome must both be set if denominazione is empty", ]) a.denominazione = "Test Test1" self.assert_validates(a, errors=[ "cognome: cognome must not be set if denominazione is not empty", ]) a.denominazione = "Test Test1" a.nome = "Test" self.assert_validates(a, errors=[ "nome: nome and cognome must not be set if denominazione is not empty", "cognome: nome and cognome must not be set if denominazione is not empty", ]) a.cognome = None self.assert_validates(a, errors=[ "nome: nome must not be set if denominazione is not empty", ]) a.nome = None self.assert_validates(a) class TestDatiTrasmissione(TestFatturaMixin, TestCase): def test_validation(self): dt = a38.DatiTrasmissione( a38.IdTrasmittente("ID", "1234567890"), "12345", "FPR12") self.assert_validates(dt, errors=[ # "codice_destinatario: one of codice_destinatario or pec_destinatario must be set", # "pec_destinatario: one of codice_destinatario or pec_destinatario must be set", "codice_destinatario: [00426] pec_destinatario has no value while codice_destinatario has value 0000000", "pec_destinatario: [00426] pec_destinatario has no value while codice_destinatario has value 0000000", ]) dt.codice_destinatario = "FUFUFU" self.assert_validates(dt, errors=[ "codice_destinatario: [00427] codice_destinatario has 6 characters on a Fattura Privati", ]) dt.codice_destinatario = "FUFUFUF" self.assert_validates(dt) dt.pec_destinatario = "local_part@example.org" self.assert_validates(dt, errors=[ "codice_destinatario: [00426] pec_destinatario has value while codice_destinatario has value 0000000", "pec_destinatario: [00426] pec_destinatario has value while codice_destinatario has value 0000000", ]) dt.codice_destinatario = None self.assert_validates(dt) class TestDatiBeniServizi(TestFatturaMixin, TestCase): def test_add_dettaglio_linee(self): o = a38.DatiBeniServizi() o.add_dettaglio_linee( descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, aliquota_iva=22) o.add_dettaglio_linee( descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", aliquota_iva=22) self.assertEqual(len(o.dettaglio_linee), 2) self.assertEqual(o.dettaglio_linee[0], a38.DettaglioLinee( numero_linea=1, descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, prezzo_totale=14, aliquota_iva=22)) self.assertEqual(o.dettaglio_linee[1], a38.DettaglioLinee( numero_linea=2, descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", prezzo_totale="0.4", aliquota_iva=22)) def test_add_dettaglio_linee_without_quantita(self): o = a38.DatiBeniServizi() o.add_dettaglio_linee(descrizione="Line 1", prezzo_unitario=7, aliquota_iva=22) self.assertEqual(len(o.dettaglio_linee), 1) self.assertEqual( o.dettaglio_linee[0], a38.DettaglioLinee(1, descrizione="Line 1", prezzo_unitario=7, prezzo_totale=7, aliquota_iva=22)) def test_build_dati_riepilogo(self): o = a38.DatiBeniServizi() o.add_dettaglio_linee(descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, aliquota_iva=22) o.add_dettaglio_linee( descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", aliquota_iva=22) o.add_dettaglio_linee( descrizione="Line 3", quantita="3.5", unita_misura="A", prezzo_unitario="0.5", aliquota_iva=10) o.build_dati_riepilogo() self.assertEqual(len(o.dati_riepilogo), 2) self.assertEqual( o.dati_riepilogo[0], a38.DatiRiepilogo(aliquota_iva="10", imponibile_importo="1.75", imposta="0.175", esigibilita_iva="I")) self.assertEqual( o.dati_riepilogo[1], a38.DatiRiepilogo(aliquota_iva="22", imponibile_importo="14.40", imposta="3.168", esigibilita_iva="I")) def test_build_dati_riepilogo_natura(self): self.maxDiff = None common_args = {"descrizione": "Line", "quantita": 1, "unita_misura": "N"} o = a38.DatiBeniServizi() o.add_dettaglio_linee(prezzo_unitario=7, aliquota_iva=22, natura="N1", **common_args) o.add_dettaglio_linee(prezzo_unitario="1", aliquota_iva=22, natura="N1", **common_args) o.add_dettaglio_linee(prezzo_unitario="3.5", aliquota_iva=10, natura="N6", **common_args) o.add_dettaglio_linee(prezzo_unitario="3.5", aliquota_iva=0, natura="N2.1", **common_args) o.add_dettaglio_linee(prezzo_unitario="7.5", aliquota_iva=0, natura="N2.1", **common_args) o.add_dettaglio_linee(prezzo_unitario="5", aliquota_iva=0, natura="N3.1", **common_args) o.build_dati_riepilogo() self.assertEqual(len(o.dati_riepilogo), 4) self.assertEqual( o.dati_riepilogo[0], a38.DatiRiepilogo( aliquota_iva="0", imponibile_importo="11", imposta="0", esigibilita_iva="I", natura="N2.1")) self.assertEqual( o.dati_riepilogo[1], a38.DatiRiepilogo( aliquota_iva="0", imponibile_importo="5", imposta="0", esigibilita_iva="I", natura="N3.1")) self.assertEqual( o.dati_riepilogo[2], a38.DatiRiepilogo( aliquota_iva="10", imponibile_importo="3.5", imposta="0.35", esigibilita_iva="I", natura="N6")) self.assertEqual( o.dati_riepilogo[3], a38.DatiRiepilogo( aliquota_iva="22", imponibile_importo="8", imposta="1.76", esigibilita_iva="I", natura="N1")) def test_build_dati_riepilogo_natura_issue33(self): self.maxDiff = None o = a38.DatiBeniServizi() o.add_dettaglio_linee( descrizione="Bollo", quantita=1, unita_misura="EUR", prezzo_unitario="2", aliquota_iva="0.00", natura="N1") o.build_dati_riepilogo() self.assertEqual(o.dati_riepilogo[0].natura, "N1") from a38.validation import Validation res = Validation() o.validate(res) self.assertEqual(res.warnings, []) self.assertEqual(res.errors, []) class TestFatturaElettronicaBody(TestFatturaMixin, TestCase): def test_build_importo_totale_documento(self): o = a38.FatturaElettronicaBody() o.dati_beni_servizi.add_dettaglio_linee( descrizione="Line 1", quantita=2, unita_misura="m²", prezzo_unitario=7, aliquota_iva=22) o.dati_beni_servizi.add_dettaglio_linee( descrizione="Line 2", quantita=1, unita_misura="A", prezzo_unitario="0.4", aliquota_iva=22) o.dati_beni_servizi.add_dettaglio_linee( descrizione="Line 3", quantita="3.5", unita_misura="A", prezzo_unitario="0.5", aliquota_iva=10) o.dati_beni_servizi.build_dati_riepilogo() o.build_importo_totale_documento() # It would be 19.49 if all operations where made in full float, but # since we first compute imponibile and imposta, fit them into the # required number of digits, then add them up, 19.50 is what we get self.assertEqual(o.dati_generali.dati_generali_documento.importo_totale_documento, Decimal("19.50")) def test_rounding_xml(self): f = a38.FatturaPrivati12() o = f.fattura_elettronica_body[0] o.dati_beni_servizi.add_dettaglio_linee( descrizione="Line 1", prezzo_unitario="0.35", aliquota_iva=10, unita_misura='pz', quantita=1) o.dati_beni_servizi.build_dati_riepilogo() o.build_importo_totale_documento() self.assertEqual( f.fattura_elettronica_body[0].dati_generali.dati_generali_documento.importo_totale_documento, Decimal("0.39")) self.assertEqual(f.build_etree().getroot().find('.//ImportoTotaleDocumento').text, "0.39") class TestFatturaPrivati12(TestFatturaMixin, TestCase): def build_sample(self): cedente_prestatore = a38.CedentePrestatore( a38.DatiAnagraficiCedentePrestatore( a38.IdFiscaleIVA("IT", "01234567890"), codice_fiscale="NTNBLN22C23A123U", anagrafica=a38.Anagrafica(denominazione="Test User"), regime_fiscale="RF01", ), a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"), iscrizione_rea=a38.IscrizioneREA( ufficio="FI", numero_rea="123456", stato_liquidazione="LN", ), contatti=a38.Contatti(email="local_part@pec_domain.it"), ) cessionario_committente = a38.CessionarioCommittente( a38.DatiAnagraficiCessionarioCommittente( a38.IdFiscaleIVA("IT", "76543210987"), anagrafica=a38.Anagrafica(denominazione="A Company SRL"), ), a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"), ) f = a38.FatturaPrivati12() f.fattura_elettronica_header.dati_trasmissione.update( a38.IdTrasmittente("IT", "10293847561"), codice_destinatario="FUFUFUF", ) f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore f.fattura_elettronica_header.cessionario_committente = cessionario_committente body = f.fattura_elettronica_body[0] body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento( tipo_documento="TD01", divisa="EUR", data=datetime.date(2019, 1, 1), numero=1, causale=["Test billing"], ) body.dati_beni_servizi.add_dettaglio_linee( descrizione="Test item", quantita=2, unita_misura="kg", prezzo_unitario="25.50", aliquota_iva="22.00") body.dati_beni_servizi.add_dettaglio_linee( descrizione="Other item", quantita=1, unita_misura="kg", prezzo_unitario="15.50", aliquota_iva="22.00") body.dati_beni_servizi.build_dati_riepilogo() body.build_importo_totale_documento() f.fattura_elettronica_body.append(body) return f def test_initial_body_exists(self): f = a38.FatturaPrivati12() self.assertEqual(len(f.fattura_elettronica_body), 1) self.assertFalse(f.fattura_elettronica_body[0].has_value()) def test_validate(self): f = self.build_sample() self.assertEqual(f.fattura_elettronica_header.dati_trasmissione.formato_trasmissione, "FPR12") self.assert_validates(f) def test_serialize(self): f = self.build_sample() self.assertEqual(f.fattura_elettronica_header.dati_trasmissione.formato_trasmissione, "FPR12") tree = f.build_etree() with io.StringIO() as out: tree.write(out, encoding="unicode") xml = out.getvalue() self.assertIn( '', xml) self.assertIn('FPR12', xml) def test_serialize_lxml(self): from a38 import builder if not builder.HAVE_LXML: raise SkipTest("lxml is not available") f = self.build_sample() tree = f.build_etree(lxml=True) with io.BytesIO() as out: tree.write(out) xml = out.getvalue() self.assertIn( b'', xml) self.assertIn(b'FPR12', xml) def test_to_python(self): f = self.build_sample() py = f.to_python(namespace="a38") parsed = eval(py) self.assertEqual(f, parsed) def test_parse(self): f = self.build_sample() tree = f.build_etree() with io.StringIO() as out: tree.write(out, encoding="unicode") xml1 = out.getvalue() f = a38.FatturaPrivati12() f.from_etree(tree.getroot()) self.assert_validates(f) tree = f.build_etree() with io.StringIO() as out: tree.write(out, encoding="unicode") xml2 = out.getvalue() self.assertEqual(xml1, xml2) f = a38.auto_from_etree(tree.getroot()) self.assert_validates(f) tree = f.build_etree() with io.StringIO() as out: tree.write(out, encoding="unicode") xml2 = out.getvalue() class TestSamples(TestFatturaMixin, TestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.codecs = [ # No p7m because it cannot write codec.XML(), codec.JSON(), codec.YAML(), codec.Python(loadable=True), ] def assertCodecsCanCope(self, f): for cod in self.codecs: with self.subTest(codec=cod.__class__.__name__): with tempfile.NamedTemporaryFile() as tf: cod.save(f, tf.name) f1 = cod.load(tf.name) self.assertEqual(f, f1) def test_parse_dati_trasporto(self): import xml.etree.ElementTree as ET tree = ET.parse("tests/data/dati_trasporto.xml") f = a38.auto_from_etree(tree.getroot()) self.assertCodecsCanCope(f) def test_parse_unicode(self): import xml.etree.ElementTree as ET tree = ET.parse("tests/data/unicode.xml") f = a38.auto_from_etree(tree.getroot()) self.assertCodecsCanCope(f) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704882565.0 a38-0.1.8/tests/test_fields.py0000644000177700017770000006717714547470605016406 0ustar00valhallavalhallaimport datetime import io from decimal import Decimal from unittest import TestCase from a38 import fields, models, validation from a38.builder import Builder from a38.diff import Diff NS = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2" class FieldTestMixin: field_class = fields.Field def get_field(self, *args, **kw): f = self.field_class(*args, **kw) f.set_name("sample") return f def test_xmltag(self): # XML tag is autogenerated from the field name f = self.get_field() self.assertEqual(f.get_xmltag(), "Sample") # But can be overridden with the xmltag argument f = self.get_field(xmltag="OtherName") self.assertEqual(f.get_xmltag(), "OtherName") def test_empty(self): f = self.get_field() # Validating a field with null=False raises an error self.assert_validates(f, None, result=None, errors=[ "sample: missing value", ]) # But null values are tolerated outside validation, while structures # are being filled self.assertIsNone(f.clean_value(None)) # Values set to None are skipped in XML self.assertIsNone(self.to_xml(f, None)) def test_nullable(self): f = self.get_field(null=True) self.assert_validates(f, None, result=None) self.assertIsNone(f.clean_value(None)) def test_construct_default(self): f = self.get_field() self.assertIsNone(f.get_construct_default()) def test_value(self): f = self.get_field() self.assert_validates(f, "value", result="value") def test_default(self): f = self.get_field(default="default") self.assertEqual(f.clean_value(None), "default") self.assertEqual(self.to_xml(f, None), "default") def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, "value"), "value") def test_to_python_none(self): f = self.get_field() self.assert_to_python_works(f, None) def test_diff_none(self): f = self.get_field() self.assert_diff_empty(f, None, None) def assert_validates(self, field, value, result, warnings=[], errors=[]): val = validation.Validation() validated = field.validate(val, value) self.assertEqual([str(x) for x in val.warnings], warnings) self.assertEqual([str(x) for x in val.errors], errors) self.assertEqual(validated, result) def assert_to_python_works(self, field, value, **kw): kw.setdefault("namespace", False) py = field.to_python(value, **kw) try: parsed = eval(py) except Exception as e: self.fail("cannot parse generated python {}: {}".format(repr(py), str(e))) clean = field.clean_value(parsed) self.assertEqual(clean, field.clean_value(value)) def assert_diff_empty(self, field, first, second): """ Check that the field diff between the two values is empty """ res = Diff() field.diff(res, first, second) if res.differences: self.assertEqual([(d.prefix, d.field, d.first, d.second) for d in res.differences], []) def assert_diff(self, field, first, second, expected): """ Check that the field diff between the two differing values, is as expected """ res = Diff() field.diff(res, first, second) self.assertEqual([str(d) for d in res.differences], expected) def assert_field_diff(self, field, first, second): """ Check that the field diff, from a non-composite field, between the two differing values, is as expected """ self.assert_diff(field, first, None, ["sample: second is not set"]) self.assert_diff(field, None, first, ["sample: first is not set"]) self.assert_diff(field, second, None, ["sample: second is not set"]) self.assert_diff(field, None, second, ["sample: first is not set"]) self.assert_diff(field, first, second, ["sample: first: {}, second: {}".format( field.to_str(field.clean_value(first)), field.to_str(field.clean_value(second)))]) def to_xml(self, field, value): """ Serialize the field to XML. Returns None is the field generated no value in the XML. """ builder = Builder() with builder.element("T"): field.to_xml(builder, value) tree = builder.get_tree() root = tree.getroot() if not list(root): return None with io.StringIO() as out: tree.write(out, encoding="unicode") return out.getvalue() def mkdt(self, ye, mo, da, ho, mi, se=0, tz=None): if tz is None: tz = fields.DateTimeField.tz_rome return tz.localize(datetime.datetime(ye, mo, da, ho, mi, se)) class TestField(FieldTestMixin, TestCase): pass class TestStringField(FieldTestMixin, TestCase): field_class = fields.StringField def test_value(self): f = self.get_field() self.assert_validates(f, "value", result="value") self.assert_validates(f, 12, result="12") def test_default(self): f = self.get_field(default="default") self.assertEqual(f.clean_value(None), "default") self.assertEqual(self.to_xml(f, None), "default") def test_length(self): f = self.get_field(length=3) self.assert_validates(f, "va", result="va", errors=[ "sample: 'va' should be at least 3 characters long", ]) self.assert_validates(f, "valu", result="valu", errors=[ "sample: 'valu' should be no more than 3 characters long", ]) self.assert_validates(f, 1.15, result="1.15", errors=[ "sample: '1.15' should be no more than 3 characters long", ]) self.assert_validates(f, "val", result="val") self.assert_validates(f, 1.2, result="1.2") def test_min_length(self): f = self.get_field(min_length=3) self.assert_validates(f, "va", result="va", errors=[ "sample: 'va' should be at least 3 characters long", ]) self.assert_validates(f, "valu", result="valu") self.assert_validates(f, "val", result="val") self.assert_validates(f, 1.2, result="1.2") self.assert_validates(f, 1.15, result="1.15") def test_max_length(self): f = self.get_field(max_length=3) self.assert_validates(f, "v", result="v") self.assert_validates(f, "va", result="va") self.assert_validates(f, "val", result="val") self.assert_validates(f, "valu", result="valu", errors=[ "sample: 'valu' should be no more than 3 characters long", ]) def test_choices(self): f = self.get_field(choices=("A", "B")) self.assert_validates(f, "A", result="A") self.assert_validates(f, "B", result="B") self.assert_validates(f, "C", result="C", errors=[ "sample: 'C' is not a valid choice for this field", ]) self.assert_validates(f, "a", result="a", errors=[ "sample: 'a' is not a valid choice for this field", ]) self.assert_validates(f, None, result=None, errors=[ "sample: missing value", ]) def test_choices_nullable(self): f = self.get_field(choices=("A", "B"), null=True) self.assert_validates(f, "A", result="A") self.assert_validates(f, "B", result="B") self.assert_validates(f, None, result=None) self.assert_validates(f, "C", result="C", errors=[ "sample: 'C' is not a valid choice for this field", ]) self.assert_validates(f, "a", result="a", errors=[ "sample: 'a' is not a valid choice for this field", ]) def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, "") self.assert_to_python_works(f, "foo") self.assert_to_python_works(f, "'\"\n") self.assert_to_python_works(f, r"\d\t\n") def test_diff(self): f = self.get_field() self.assert_diff_empty(f, "", "") self.assert_diff_empty(f, "a", "a") self.assert_field_diff(f, "a", "b") def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, "value"), "value") class TestIntegerField(FieldTestMixin, TestCase): field_class = fields.IntegerField def test_value(self): f = self.get_field() self.assert_validates(f, 12, result=12) self.assert_validates(f, "12", result=12) self.assert_validates(f, 12.3, result=12) self.assert_validates(f, "foo", result="foo", errors=[ "sample: invalid literal for int() with base 10: 'foo'", ]) def test_default(self): f = self.get_field(default=7) self.assertEqual(f.clean_value(None), 7) self.assertEqual(self.to_xml(f, None), "7") def test_max_length(self): f = self.get_field(max_length=3) self.assert_validates(f, 1, result=1) self.assert_validates(f, 12, result=12) self.assert_validates(f, 123, result=123) self.assert_validates(f, 1234, result=1234, errors=[ "sample: '1234' should be no more than 3 digits long", ]) def test_choices(self): f = self.get_field(choices=(1, 2)) self.assert_validates(f, 1, result=1) self.assert_validates(f, 2, result=2) self.assert_validates(f, 3, result=3, errors=[ "sample: 3 is not a valid choice for this field", ]) self.assert_validates(f, None, result=None, errors=[ "sample: missing value", ]) def test_choices_nullable(self): f = self.get_field(choices=(1, 2), null=True) self.assert_validates(f, 1, result=1) self.assert_validates(f, 2, result=2) self.assert_validates(f, 3, result=3, errors=[ "sample: 3 is not a valid choice for this field", ]) self.assert_validates(f, None, result=None) def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, 1) self.assert_to_python_works(f, 123456) self.assert_to_python_works(f, 3 ** 80) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, 1, "1") self.assert_field_diff(f, 1, "2") def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, 1), "1") class TestDecimalField(FieldTestMixin, TestCase): field_class = fields.DecimalField def test_value(self): f = self.get_field() self.assert_validates(f, 12, result=Decimal("12.00")) self.assert_validates(f, "12", result=Decimal("12.00")) self.assert_validates(f, "12.345", result=Decimal("12.35")) self.assert_validates(f, "foo", result="foo", errors=[ "sample: 'foo' cannot be converted to Decimal", ]) def test_decimals_fixed(self): f = self.get_field(decimals=3) self.assert_validates(f, 12, result=Decimal("12.000")) self.assert_validates(f, "12", result=Decimal("12.000")) self.assert_validates(f, "12.345", result=Decimal("12.345")) self.assert_validates(f, "12.345678", result=Decimal("12.346")) self.assert_validates(f, "foo", result="foo", errors=[ "sample: 'foo' cannot be converted to Decimal", ]) def test_decimals_range(self): f = self.get_field(decimals=(2, 6)) self.assert_validates(f, 12, result=Decimal("12.00")) self.assert_validates(f, "12", result=Decimal("12.00")) self.assert_validates(f, "12.345", result=Decimal("12.345")) self.assert_validates(f, "12.345678", result=Decimal("12.345678")) self.assert_validates(f, "12.3456789", result=Decimal("12.345679")) self.assert_validates(f, "foo", result="foo", errors=[ "sample: 'foo' cannot be converted to Decimal", ]) def test_default(self): f = self.get_field(default="7.0") self.assertEqual(f.clean_value(None), Decimal("7.0")) self.assertEqual(self.to_xml(f, None), "7.00") def test_max_length(self): f = self.get_field(max_length=4) self.assert_validates(f, 1, result=Decimal("1.00")) # 12 becomes 12.00 which is 5 characters long on a max_length of 4 self.assert_validates(f, 12, result=Decimal("12.00"), errors=[ "sample: '12.00' should be no more than 4 digits long", ]) def test_choices(self): f = self.get_field(choices=("1.1", "2.2"), decimals=1) self.assert_validates(f, "1.1", result=Decimal("1.1")) self.assert_validates(f, Decimal("2.2"), result=Decimal("2.2")) # 1.1 does not have an exact decimal representation, but clean_value # will constrain it to the configured number of digits self.assert_validates(f, 1.1, result=Decimal("1.1")) self.assert_validates(f, None, result=None, errors=[ "sample: missing value", ]) def test_choices_nullable(self): f = self.get_field(choices=("1.1", "2.2"), null=True, decimals=1) self.assert_validates(f, "1.1", result=Decimal("1.1")) self.assert_validates(f, Decimal("2.2"), result=Decimal("2.2")) self.assert_validates(f, None, result=None) # 1.1 does not have an exact decimal representation, but gets fit into # the allowed decimals self.assert_validates(f, 1.1, result=Decimal("1.1")) def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, "1.2") self.assert_to_python_works(f, Decimal("1.20")) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, Decimal("1.0"), "1.0") self.assert_diff_empty(f, "1.0001", "1.0002") self.assert_field_diff(f, "1.1", "1.2") def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, "12.345"), "12.35") self.assertEqual(self.to_xml(f, "34.567"), "34.57") class TestDateField(FieldTestMixin, TestCase): field_class = fields.DateField def test_value(self): f = self.get_field() self.assert_validates(f, datetime.date(2019, 1, 2), result=datetime.date(2019, 1, 2)) self.assert_validates(f, "2019-01-02", result=datetime.date(2019, 1, 2)) self.assert_validates(f, datetime.datetime(2019, 1, 2, 12, 30), result=datetime.date(2019, 1, 2)) self.assert_validates(f, "foo", result="foo", errors=[ "sample: Date 'foo' does not begin with YYYY-mm-dd", ]) self.assert_validates(f, [123], result=[123], errors=[ "sample: '[123]' is not an instance of str, datetime.date or datetime.datetime", ]) def test_default(self): f = self.get_field(default="2019-01-02") self.assertEqual(f.clean_value(None), datetime.date(2019, 1, 2)) self.assertEqual(self.to_xml(f, None), "2019-01-02") def test_choices(self): f = self.get_field(choices=("2019-01-01", "2019-01-02")) self.assert_validates(f, "2019-01-01", result=datetime.date(2019, 1, 1)) self.assert_validates(f, "2019-01-02", result=datetime.date(2019, 1, 2)) self.assert_validates(f, "2019-01-03", result=datetime.date(2019, 1, 3), errors=[ "sample: datetime.date(2019, 1, 3) is not a valid choice for this field", ]) self.assert_validates(f, None, result=None, errors=[ "sample: missing value", ]) def test_choices_nullable(self): f = self.get_field(choices=("2019-01-01", "2019-01-02"), null=True) self.assert_validates(f, "2019-01-01", result=datetime.date(2019, 1, 1)) self.assert_validates(f, "2019-01-02", result=datetime.date(2019, 1, 2)) self.assert_validates(f, "2019-01-03", result=datetime.date(2019, 1, 3), errors=[ "sample: datetime.date(2019, 1, 3) is not a valid choice for this field", ]) self.assert_validates(f, None, result=None) def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, "2019-01-03") self.assert_to_python_works(f, datetime.date(2019, 2, 4)) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, datetime.date(2019, 1, 1), "2019-01-01") self.assert_field_diff(f, datetime.date(2019, 1, 1), "2019-01-02") self.assert_field_diff(f, "2019-01-01", "2019-01-02") def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, datetime.date(2019, 1, 2)), "2019-01-02") self.assertEqual(self.to_xml(f, "2019-01-02"), "2019-01-02") class TestDateTimeField(FieldTestMixin, TestCase): field_class = fields.DateTimeField def test_value(self): f = self.get_field() self.assert_validates(f, datetime.datetime(2019, 1, 2, 12, 30), result=self.mkdt(2019, 1, 2, 12, 30)) self.assert_validates(f, "2019-01-02T12:30:00", result=self.mkdt(2019, 1, 2, 12, 30)) self.assert_validates(f, datetime.datetime(2019, 1, 2, 12, 30), result=self.mkdt(2019, 1, 2, 12, 30)) self.assert_validates(f, "foo", result="foo", errors=[ "sample: ISO string too short", ]) self.assert_validates(f, [123], result=[123], errors=[ "sample: '[123]' is not an instance of str, datetime.date or datetime.datetime", ]) def test_default(self): f = self.get_field(default="2019-01-02T12:30:00") self.assertEqual(f.clean_value(None), self.mkdt(2019, 1, 2, 12, 30)) self.assertEqual(self.to_xml(f, None), "2019-01-02T12:30:00+01:00") def test_choices(self): f = self.get_field(choices=("2019-01-01T12:00:00", "2019-01-02T12:30:00")) self.assert_validates(f, "2019-01-01T12:00:00", result=self.mkdt(2019, 1, 1, 12, 00)) self.assert_validates(f, "2019-01-02T12:30:00", result=self.mkdt(2019, 1, 2, 12, 30)) self.assert_validates(f, self.mkdt(2019, 1, 2, 12, 15), result=self.mkdt(2019, 1, 2, 12, 15), errors=[ "sample: 2019-01-02T12:15:00+01:00 is not a valid choice for this field", ]) self.assert_validates(f, None, result=None, errors=[ "sample: missing value", ]) def test_choices_nullable(self): f = self.get_field(choices=("2019-01-01T12:00:00", "2019-01-02T12:30:00"), null=True) self.assert_validates(f, "2019-01-01T12:00:00", result=self.mkdt(2019, 1, 1, 12, 00)) self.assert_validates(f, "2019-01-02T12:30:00", result=self.mkdt(2019, 1, 2, 12, 30)) self.assert_validates(f, None, result=None) self.assert_validates(f, self.mkdt(2019, 1, 2, 12, 15), result=self.mkdt(2019, 1, 2, 12, 15), errors=[ "sample: 2019-01-02T12:15:00+01:00 is not a valid choice for this field", ]) def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, "2019-01-03T04:05:06") self.assert_to_python_works(f, self.mkdt(2019, 1, 2, 3, 4, 5)) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, self.mkdt(2019, 1, 2, 3, 4, 5), "2019-01-02T03:04:05+01:00") self.assert_field_diff(f, self.mkdt(2019, 1, 2, 3, 4, 5), self.mkdt(2019, 1, 2, 3, 4, 6)) self.assert_field_diff(f, self.mkdt(2019, 1, 2, 3, 4, 5), "2019-01-02T03:04:05+02:00") def test_xml(self): f = self.get_field(null=True) self.assertEqual( self.to_xml(f, self.mkdt(2019, 1, 2, 12, 30)), "2019-01-02T12:30:00+01:00") self.assertEqual(self.to_xml(f, "2019-01-02T12:13:14"), "2019-01-02T12:13:14+01:00") class TestProgressivoInvioField(FieldTestMixin, TestCase): field_class = fields.ProgressivoInvioField def test_construct_default(self): f = self.get_field() # The field generates always different, always increasing values a = f.get_construct_default() b = f.get_construct_default() c = f.get_construct_default() d = f.get_construct_default() self.assertNotEqual(a, b) self.assertNotEqual(a, c) self.assertNotEqual(a, d) self.assertNotEqual(b, c) self.assertNotEqual(b, d) self.assertNotEqual(c, d) self.assertLess(a, b) self.assertLess(b, c) self.assertLess(c, d) def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, "BFABFAF") class Sample(models.Model): name = fields.StringField() value = fields.IntegerField() class TestModelField(FieldTestMixin, TestCase): field_class = fields.ModelField def get_field(self, *args, **kw): return super().get_field(Sample, *args, **kw) def test_construct_default(self): f = self.get_field() value = f.get_construct_default() self.assertIsInstance(value, Sample) self.assertIsNone(value.name) self.assertIsNone(value.value) def test_empty(self): super().test_empty() # Empty models are skipped in XML f = self.get_field() self.assertIsNone(self.to_xml(f, Sample())) def test_value(self): f = self.get_field() self.assert_validates(f, Sample("test", 7), result=Sample("test", 7)) def test_default(self): f = self.get_field(default=Sample("test", 7)) self.assertEqual(f.clean_value(None), Sample("test", 7)) self.assertEqual(self.to_xml(f, None), "test7") def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, Sample("test", 7)) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, Sample("test", 7), Sample("test", "7")) self.assert_diff(f, Sample("test", 6), None, [ "sample: second is not set", ]) self.assert_diff(f, Sample("test", 6), Sample("test", 7), [ "sample.value: first: 6, second: 7", ]) self.assert_diff(f, Sample("test1", 6), Sample("test2", 7), [ "sample.name: first: test1, second: test2", "sample.value: first: 6, second: 7", ]) def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, Sample("test", 7)), "test7") class TestModelListField(FieldTestMixin, TestCase): field_class = fields.ModelListField def get_field(self, *args, **kw): return super().get_field(Sample, *args, **kw) def test_construct_default(self): f = self.get_field() value = f.get_construct_default() self.assertEqual(value, []) def test_value(self): f = self.get_field() self.assert_validates(f, [], result=[], errors=[ "sample: missing value", ]) self.assert_validates(f, [Sample("test", 7)], result=[Sample("test", 7)]) f = self.get_field(null=True) self.assert_validates(f, [], result=[]) def test_min_num(self): f = self.get_field(min_num=2) self.assertEqual(f.get_construct_default(), [Sample(), Sample()]) self.assertEqual(f.clean_value([Sample(), Sample(), Sample()]), [Sample(), Sample()]) self.assert_validates(f, [Sample("test", 7)], result=[Sample("test", 7)], errors=[ "sample: list must have at least 2 elements, but has only 1", ]) self.assert_validates(f, [Sample("test", 6), Sample("test", 7)], result=[Sample("test", 6), Sample("test", 7)]) def test_default(self): f = self.get_field(default=[Sample("test", 7)]) self.assertEqual(f.clean_value(None), [Sample("test", 7)]) self.assertEqual(self.to_xml(f, None), "test7") def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, [Sample("test", 7)]) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, [], None) self.assert_diff_empty(f, [Sample("test", 7)], [Sample("test", "7")]) self.assert_diff_empty(f, [Sample("test", 6)], [Sample("test", 6), None]) self.assert_diff(f, [Sample("test", 6)], None, [ "sample: second is not set", ]) self.assert_diff(f, [Sample("test", 6)], [], [ "sample: second is not set", ]) self.assert_diff(f, [Sample("test", 6)], [Sample("test", 7)], [ "sample.0.value: first: 6, second: 7", ]) self.assert_diff(f, [Sample("test", 6)], [Sample("test", 6), Sample("test", 7)], [ "sample: second has 1 extra element", ]) self.assert_diff(f, [Sample("test", 6)], [Sample("test", 5), Sample("test", 7)], [ "sample.0.value: first: 6, second: 5", "sample: second has 1 extra element", ]) def test_xml(self): f = self.get_field(null=True) self.assertIsNone(self.to_xml(f, [])) self.assertEqual( self.to_xml(f, [Sample("test", 7)]), "test7") class TestListField(FieldTestMixin, TestCase): field_class = fields.ListField def get_field(self, *args, **kw): return super().get_field(fields.StringField(), *args, **kw) def test_construct_default(self): f = self.get_field() value = f.get_construct_default() self.assertEqual(value, []) def test_value(self): f = self.get_field() self.assert_validates(f, [], result=[], errors=[ "sample: missing value", ]) self.assert_validates(f, ["test1", "test2"], result=["test1", "test2"]) f = self.get_field(null=True) self.assert_validates(f, [], result=[]) def test_min_num(self): f = self.get_field(min_num=2) self.assertEqual(f.get_construct_default(), [None, None]) self.assertEqual(f.clean_value([None, None, None]), [None, None]) self.assert_validates(f, ["test1"], result=["test1"], errors=[ "sample: list must have at least 2 elements, but has only 1", ]) self.assert_validates(f, ["test1", "test2"], result=["test1", "test2"]) def test_default(self): f = self.get_field(default=["test1", "test2"]) self.assertEqual(f.clean_value(None), ["test1", "test2"]) self.assertEqual(self.to_xml(f, None), "test1test2") def test_to_python(self): f = self.get_field() self.assert_to_python_works(f, ["test1", "foo"]) f = super().get_field(fields.DateTimeField()) self.assert_to_python_works(f, [self.mkdt(2019, 1, 2, 3, 4), self.mkdt(2019, 2, 3, 4, 5)]) def test_diff(self): f = self.get_field() self.assert_diff_empty(f, [], None) self.assert_diff_empty(f, ["test"], ["test"]) self.assert_diff_empty(f, ["test"], ["test", None]) self.assert_diff(f, ["test"], ["test1"], [ "sample.0: first: test, second: test1", ]) self.assert_diff(f, ["test"], ["test", "test"], [ "sample: second has 1 extra element", ]) self.assert_diff(f, ["test"], ["test1", "test2"], [ "sample.0: first: test, second: test1", "sample: second has 1 extra element", ]) def test_xml(self): f = self.get_field(null=True) self.assertIsNone(self.to_xml(f, [])) self.assertEqual(self.to_xml(f, ["test", "foo"]), "testfoo") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1676539966.0 a38-0.1.8/tests/test_models.py0000644000177700017770000000405414373374076016406 0ustar00valhallavalhallafrom unittest import TestCase from a38 import fields, models class Sample(models.Model): name = fields.StringField() value = fields.IntegerField() class Sample1(models.Model): name = fields.StringField() type = fields.StringField(choices=("A", "B")) class TestModel(TestCase): def test_assignment(self): o = Sample() # Values are cleaned on assignment o.name = 12 o.value = "42" self.assertEqual(o.name, "12") self.assertEqual(o.value, 42) def test_misspelled_field(self): o = Sample() with self.assertRaises(AttributeError): o.nome = "foo" def test_clean_value(self): # Assign from a model val = Sample.clean_value(Sample1("foo", "A")) self.assertIsInstance(val, Sample) self.assertEqual(val.name, "foo") self.assertIsNone(val.value) # Assign from a dict val = Sample.clean_value({"name": "foo", "type": "A"}) self.assertIsInstance(val, Sample) self.assertEqual(val.name, "foo") self.assertIsNone(val.value) self.assertIsNone(Sample.clean_value(None)) with self.assertRaises(TypeError): Sample.clean_value("foo") def test_compare(self): self.assertEqual(Sample("test", 7), Sample("test", 7)) self.assertEqual(Sample(), None) self.assertNotEqual(Sample("test", 7), Sample("test", 6)) self.assertNotEqual(Sample("test", 7), None) self.assertLess(Sample("test", 6), Sample("test", 7)) self.assertLessEqual(Sample("test", 6), Sample("test", 7)) self.assertLessEqual(Sample("test", 7), Sample("test", 7)) self.assertLessEqual(Sample(), None) self.assertGreater(Sample("test", 7), Sample("test", 6)) self.assertGreater(Sample("test", 7), None) self.assertGreaterEqual(Sample("test", 7), Sample("test", 6)) self.assertGreaterEqual(Sample("test", 7), Sample("test", 7)) self.assertGreaterEqual(Sample("test", 7), None) self.assertGreaterEqual(Sample(), None) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1704892780.0 a38-0.1.8/tests/test_p7m.py0000644000177700017770000001213014547514554015620 0ustar00valhallavalhallaimport os import tempfile from contextlib import contextmanager from unittest import TestCase from a38.crypto import P7M, InvalidSignatureError # This is the CA certificate used to validate tests/data/test.txt.p7m # # The signature on the test file will expire (next expiration date: May 6 23:59:59 2024 GMT) # # To refresh it: # # 1. Sign tests/data/test.txt with a CAdES envelope # 2. Extract the signature: # openssl smime -verify -in tests/data/test.txt.p7m -inform der -noverify -signer /tmp/cert.pem -out /dev/null # 3. Get signature information: # openssl x509 -inform pem -in /tmp/cert.pem -text # 4. Compute the issuer hash to find the CA certificate: # openssl x509 -inform pem -in /tmp/cert.pem -issuer_hash # 5. Download/refresh the CA certificate database: # ./a38tool update_capath certs # 6. Find the file named with the issuer hash in certs/ # 7. Update the CA_CERT_HASH variable below with the name of the file you just # found in certs/ # 8. Replace the value of CA_CERT with its contents # CA_CERT = """ -----BEGIN CERTIFICATE----- MIIE+jCCA+KgAwIBAgIQbK2AXjA4PMWG8x+rL26V9zANBgkqhkiG9w0BAQsFADBs MQswCQYDVQQGEwJJVDEYMBYGA1UECgwPQXJ1YmFQRUMgUy5wLkEuMSEwHwYDVQQL DBhDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eUMxIDAeBgNVBAMMF0FydWJhUEVDIFMu cC5BLiBORyBDQSAzMB4XDTEwMTAyMjAwMDAwMFoXDTMwMTAyMjIzNTk1OVowbDEL MAkGA1UEBhMCSVQxGDAWBgNVBAoMD0FydWJhUEVDIFMucC5BLjEhMB8GA1UECwwY Q2VydGlmaWNhdGlvbiBBdXRob3JpdHlDMSAwHgYDVQQDDBdBcnViYVBFQyBTLnAu QS4gTkcgQ0EgMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtkY4EH G+Nh4VYLL4R5tvmX6J+AYlL2BPDUCLN92+zi9QMbsh84zbRE+om9KE8P67mST2my bhGTz6dzeK1BrQfSdKJ8AGxePzqUq+uGHGULoy4A6ey4EyqTfxY+pGzjB7OVcuiw y7iV6k1YjshIsmNjTmYOAQepZMgBmxHPnR6IW9MsAOFBBQH/vJFQDeBts/rA6lbM /VsURwzr6XOqCzwJK/csKvuE/rAaRKY+IPzah8mou//yEi4V401J1JYfPanbCJOW nIty9HaioUe5Fu2jw4UP7T5Cbw4lND1sP7HVhsVRDuTj3gF9ulJ7EBmcR/2THDZC ozD76uwuTmkm4VsCAwEAAaOCAZYwggGSMD8GCCsGAQUFBwEBBDMwMTAvBggrBgEF BQcwAYYjaHR0cDovL29jc3AuYXJ1YmFwZWMudHJ1c3RpdGFsaWEuaXQwEgYDVR0T AQH/BAgwBgEB/wIBADBGBgNVHSAEPzA9MDsGCisGAQQBgegtAQEwLTArBggrBgEF BQcCARYfaHR0cHM6Ly9jYS5hcnViYXBlYy5pdC9jcHMuaHRtbDBqBgNVHR8EYzBh MF+gXaBbhllodHRwOi8vb25zaXRlY3JsLmFydWJhcGVjLnRydXN0aXRhbGlhLml0 L0FydWJhUEVDU3BBQ2VydGlmaWNhdGlvbkF1dGhvcml0eUMvTGF0ZXN0Q1JMLmNy bDArBgNVHRIEJDAipCAwHjEcMBoGA1UEAxMTR09WVlNQLUMxLTIwNDgtMS0xMDAO BgNVHQ8BAf8EBAMCAQYwKwYDVR0RBCQwIqQgMB4xHDAaBgNVBAMTE0dPVlZTUC1D MS0yMDQ4LTEtMTAwHQYDVR0OBBYEFPDARbG2NbTqXyn6gwNK3C/1s33oMA0GCSqG SIb3DQEBCwUAA4IBAQBRGwGypquxMawPV6ZN5l/2eJdaaqgnYolin1PGXJUFRQy3 k5FK0Fwk/90U/j/ue83cYdsRpPVpo17LOk7hCNSFk/W2SRVGvqaM77/cVpgFwm25 Ab2x5sMxwJ9Uoouba00CDl2SiYgn9KN+Bd3LHrwtpO8IkzwSE7k0kKmDLdCZTyUO ZPR8RKpwedjLJoiyXCtq9PKA3avI1R6N8yOxbK954+nSOsHfmGDP4wQi8PUJIWBm dlpHNM669BLdLwj6lpCjNI6AuP4K5Jw1qkOmcccnVWxkk0r2qNu87AlVosHpKf6G jkJbJNWfBsgjRHGg6Pq3enAf8/7DfkoCyKUzI8zZ -----END CERTIFICATE----- """ CA_CERT_HASH = "b72ed47c.0" class TestSignature(TestCase): @contextmanager def capath(self): with tempfile.TemporaryDirectory() as td: with open(os.path.join(td, CA_CERT_HASH), "wt") as fd: fd.write(CA_CERT) yield td def test_load(self): p7m = P7M("tests/data/test.txt.p7m") data = p7m.get_payload() self.assertEqual( data, "This is only a test payload.\n" "\n" "Questo è solo un payload di test.\n".encode("utf8")) def test_verify(self): p7m = P7M("tests/data/test.txt.p7m") if p7m.is_expired(): self.skipTest("test signature has expired and needs to be regenerated") with self.capath() as capath: p7m.verify_signature(capath) def test_verify_corrupted_random(self): p7m = P7M("tests/data/test.txt.p7m") if p7m.is_expired(): self.skipTest("test signature has expired and needs to be regenerated") data_mid = len(p7m.data) // 2 p7m.data = p7m.data[:data_mid] + bytes([p7m.data[data_mid] + 1]) + p7m.data[data_mid + 1:] with self.capath() as capath: with self.assertRaises(InvalidSignatureError): p7m.verify_signature(capath) def test_verify_corrupted_payload(self): p7m = P7M("tests/data/test.txt.p7m") if p7m.is_expired(): self.skipTest("test signature has expired and needs to be regenerated") signed_data = p7m.get_signed_data() encap_content_info = signed_data["encap_content_info"] encap_content_info["content"] = b"All your base are belong to us" p7m.data = p7m.content_info.dump() with self.capath() as capath: with self.assertRaisesRegex(InvalidSignatureError, r"routines:CMS_verify:content verify error"): p7m.verify_signature(capath) def test_verify_noca(self): p7m = P7M("tests/data/test.txt.p7m") if p7m.is_expired(): self.skipTest("test signature has expired and needs to be regenerated") with tempfile.TemporaryDirectory() as capath: with self.assertRaisesRegex( InvalidSignatureError, r"Verify error:\s*unable to get local issuer certificate"): p7m.verify_signature(capath)