././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0970638 a38-0.1.3/0000755000177700017770000000000000000000000012263 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/.gitignore0000644000177700017770000000010400000000000014246 0ustar00valhallavalhalla*.swp *.pyc /.mypy_cache /MANIFEST /.coverage /build /dist /htmlcov ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/LICENSE0000644000177700017770000002613500000000000013277 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=1559295092.0 a38-0.1.3/MANIFEST.in0000644000177700017770000000034600000000000014024 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0970638 a38-0.1.3/PKG-INFO0000644000177700017770000002106200000000000013361 0ustar00valhallavalhallaMetadata-Version: 2.1 Name: a38 Version: 0.1.3 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 Description: # Python A38 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/export/fatturazione/it/normativa/f-2.htm) ## 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.fattura as 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) ``` # 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 for [privati](https://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/fatturaordinaria_v1.2.xsl) and [PA](https://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/fatturapa_v1.2.xsl) * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip) # Copyright Copyright 2019 Truelite S.r.l. This software is released under the Apache License 2.0 Platform: UNKNOWN Requires-Python: >=3.6 Description-Content-Type: text/markdown Provides-Extra: cacerts Provides-Extra: formatted_python Provides-Extra: html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1555061764.0 a38-0.1.3/README.md0000644000177700017770000001510400000000000013543 0ustar00valhallavalhalla# Python A38 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/export/fatturazione/it/normativa/f-2.htm) ## 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.fattura as 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) ``` # 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 for [privati](https://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/fatturaordinaria_v1.2.xsl) and [PA](https://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/fatturapa_v1.2.xsl) * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip) # Copyright Copyright 2019 Truelite S.r.l. This software is released under the Apache License 2.0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0530627 a38-0.1.3/a38/0000755000177700017770000000000000000000000012656 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/a38/__init__.py0000644000177700017770000000000000000000000014755 0ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/a38/builder.py0000644000177700017770000000500700000000000014660 0ustar00valhallavalhallafrom contextlib import contextmanager import xml.etree.ElementTree as ET 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=1551964381.0 a38-0.1.3/a38/crypto.py0000644000177700017770000000627500000000000014562 0ustar00valhallavalhallafrom typing import Union, BinaryIO from asn1crypto.cms import ContentInfo import io import base64 import binascii import subprocess import xml.etree.ElementTree as ET 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 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=1550662804.0 a38-0.1.3/a38/diff.py0000644000177700017770000000437400000000000014150 0ustar00valhallavalhallafrom typing import Optional, Any, List from .traversal import Annotation, Traversal from . import fields 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=1551964381.0 a38-0.1.3/a38/fattura.py0000644000177700017770000007621600000000000014712 0ustar00valhallavalhallafrom . import models from . import fields import re # # 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`. # 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))) class IdFiscale(models.Model): id_paese = fields.StringField(length=2) id_codice = fields.StringField(max_length=28) class IdTrasmittente(IdFiscale): pass class IdFiscaleIVA(IdFiscale): pass 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) 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") 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) 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=("RF01", "RF02", "RF04", "RF05", "RF06", "RF07", "RF08", "RF09", "RF10", "RF11", "RF12", "RF13", "RF14", "RF15", "RF16", "RF17", "RF18", "RF19")) 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) class Sede(IndirizzoType): pass 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")) 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) class StabileOrganizzazione(IndirizzoType): pass 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) 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") 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) class CessionarioCommittente(models.Model): dati_anagrafici = DatiAnagraficiCessionarioCommittente sede = Sede stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True) rappresentante_fiscale = fields.ModelField(RappresentanteFiscale, null=True) class DatiAnagraficiRappresentante(models.Model): __xmltag__ = "DatiAnagrafici" id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica class RappresentanteFiscaleCedentePrestatore(models.Model): __xmltag__ = "RappresentanteFiscale" dati_anagrafici = DatiAnagraficiRappresentante class DatiAnagraficiTerzoIntermediario(models.Model): __xmltag__ = "DatiAnagrafici" id_fiscale_iva = IdFiscaleIVA codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True) anagrafica = Anagrafica class TerzoIntermediarioOSoggettoEmittente(models.Model): dati_anagrafici = DatiAnagraficiTerzoIntermediario 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) class DatiRitenuta(models.Model): tipo_ritenuta = fields.StringField(length=4, choices=("RT01", "RT02")) importo_ritenuta = fields.DecimalField(max_length=15) aliquota_ritenuta = fields.DecimalField(max_length=6) causale_pagamento = fields.StringField(max_length=2) class DatiBollo(models.Model): bollo_virtuale = fields.StringField(length=2, choices=("SI",)) importo_bollo = fields.DecimalField(max_length=15) class DatiCassaPrevidenziale(models.Model): tipo_cassa = fields.StringField(length=4, choices=["TC{:02d}".format(i) for i in range(1, 23)]) 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(length=2, choices=("N1", "N2", "N3", "N4", "N5", "N6", "N7"), 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") 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) class DatiGeneraliDocumento(models.Model): tipo_documento = fields.StringField(length=4, choices=("TD01", "TD02", "TD03", "TD04", "TD05", "TD06")) divisa = fields.StringField() data = fields.DateField() numero = fields.StringField(max_length=20) dati_ritenuta = fields.ModelField(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") 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, null=True) riferimento_data = fields.DateField(null=True) class CodiceArticolo(models.Model): codice_tipo = fields.StringField(max_length=35) codice_valore = fields.StringField(max_length=35) 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, 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) sconto_maggiorazione = fields.ModelListField(ScontoMaggiorazione, null=True) prezzo_totale = fields.DecimalField(max_length=21) aliquota_iva = fields.DecimalField(xmltag="AliquotaIVA", max_length=6) ritenuta = fields.StringField(length=2, choices=("SI",), null=True) natura = fields.StringField(length=2, null=True, choices=("N1", "N2", "N3", "N4", "N5", "N6", "N7")) 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") class DatiRiepilogo(models.Model): aliquota_iva = fields.DecimalField(xmltag="AliquotaIVA", max_length=6) natura = fields.StringField(length=2, null=True, choices=("N1", "N2", "N3", "N4", "N5", "N6", "N7")) spese_accessorie = fields.DecimalField(max_length=15, null=True) arrotondamento = fields.DecimalField(max_length=21, 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") 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) self.dettaglio_linee.append(DettaglioLinee(**kw)) # Compute prezzo_totale where not set for d in self.dettaglio_linee: if d.prezzo_totale is not None: continue if d.quantita is None: d.prezzo_totale = d.prezzo_unitario else: d.prezzo_totale = d.prezzo_unitario * d.quantita 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].append(linea) self.dati_riepilogo = [] for aliquota, linee in sorted(by_aliquota.items()): imponibile = sum(l.prezzo_totale for l in linee) imposta = imponibile * aliquota / 100 self.dati_riepilogo.append( DatiRiepilogo( aliquota_iva=aliquota, imponibile_importo=imponibile, imposta=imposta, esigibilita_iva="I")) 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) class DatiOrdineAcquisto(DatiDocumentiCorrelati): pass class DatiContratto(DatiDocumentiCorrelati): pass class DatiConvenzione(DatiDocumentiCorrelati): pass class DatiRicezione(DatiDocumentiCorrelati): pass class DatiFattureCollegate(DatiDocumentiCorrelati): pass 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) class IndirizzoResa(IndirizzoType): pass 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) 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) class FatturaPrincipale(models.Model): numero_fattura_principale = fields.StringField(max_length=20) data_fattura_principale = fields.DateField() 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) dfc_dates = [x.data for x in self.dati_fatture_collegate if x.data is not None] if dfc_dates and self.dati_generali_documento.data < min(dfc_dates): validation.add_error( (self.dati_fatture_collegate._meta["data"], self.dati_generali_documento._meta["data"]), "dati_generali_documento.data is earlier than dati_fatture_collegate.data", code="00418") class DettaglioPagamento(models.Model): beneficiario = fields.StringField(max_length=200, null=True) modalita_pagamento = fields.StringField(length=4, choices=["MP{:02d}".format(i) for i in range(1, 23)]) 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) class DatiPagamento(models.Model): condizioni_pagamento = fields.StringField(length=4, choices=("TP01", "TP02", "TP03")) dettaglio_pagamento = fields.ModelListField(DettaglioPagamento) 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() class DatiVeicoli(models.Model): data = fields.DateField() totale_percorso = fields.StringField(max_length=15) 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") 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) class FatturaPrivati12(Fattura): """ Fattura privati 1.2 """ def get_versione(self): return "FPR12" class FatturaPA12(Fattura): """ Fattura PA 1.2 """ def get_versione(self): return "FPA12" def auto_from_etree(root): 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1551964381.0 a38-0.1.3/a38/fattura_semplificata.py0000644000177700017770000001434100000000000017422 0ustar00valhallavalhallafrom .fattura import ( IdTrasmittente, IdFiscaleIVA, Sede, StabileOrganizzazione, IscrizioneREA, FullNameMixin, Allegati ) from . import models from . import fields 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=("N1", "N2", "N3", "N4", "N5", "N6", "N7")) 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=1576147384.0 a38-0.1.3/a38/fields.py0000644000177700017770000005372000000000000014505 0ustar00valhallavalhallafrom typing import Optional, Any, TypeVar, Generic, Sequence, List from dateutil.parser import isoparse import datetime import decimal import re from . import validation from . import builder from .diff import Diff from decimal import Decimal import base64 import time import pytz import logging 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=None, decimals=2, **kw): super().__init__(**kw) self.max_length = max_length self.decimals = decimals self.quantize_sample = Decimal(10) ** -decimals def clean_value(self, value): value = super().clean_value(value) if value is None: return value try: return Decimal(value) except decimal.InvalidOperation: raise TypeError("{} cannot be converted to Decimal".format(repr(value))) def to_str(self, value): if not self.has_value(value): return "None" return str(self.clean_value(value).quantize(self.quantize_sample)) 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 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=1551964381.0 a38-0.1.3/a38/models.py0000644000177700017770000002247000000000000014520 0ustar00valhallavalhallafrom typing import Dict, Any, Optional, Tuple from .fields import Field, ModelField from .validation import Validation from collections import OrderedDict, defaultdict class ModelBase: 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): @classmethod def __prepare__(self, name, bases): # See https://stackoverflow.com/questions/4459531/how-to-read-class-attributes-in-the-same-order-as-declared return OrderedDict() def __new__(cls, name, bases, dct): res = super().__new__(cls, name, bases, dct) _meta = OrderedDict() # 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) for name, val in list(dct.items()): if isinstance(val, Field): dct.pop(name) _meta[name] = val val.set_name(name) elif isinstance(val, type) and issubclass(val, ModelBase): dct.pop(name) val = ModelField(val) _meta[name] = val val.set_name(name) res._meta = _meta return res class Model(ModelBase, metaclass=ModelMetaclass): """ Declarative description of a data structure that can be validated and serialized to XML. """ 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 not isinstance(value, ModelBase): raise TypeError("{} is not a Model instance".format(value.__class__.__name__)) kw = {} for name, field in cls._meta.items(): kw[name] = getattr(value, name, None) return cls(**kw) 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=1551964381.0 a38-0.1.3/a38/render.py0000644000177700017770000000257000000000000014513 0ustar00valhallavalhallafrom typing import Optional import tempfile import subprocess 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 xml.etree ElementTree for f rendered as HTML """ tree = f.build_etree(lxml=True) return self.xslt(tree) 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) with tempfile.NamedTemporaryFile("wb", suffix=".html") as fd: html.write(fd) fd.flush() res = subprocess.run([wkhtmltopdf, fd.name, output_file], stdin=subprocess.DEVNULL, capture_output=True) if res.returncode != 0: raise RuntimeError("%s exited with error %d: stderr: %s", self.wkhtmltopdf, res.returncode, res.stderr) if output_file == "-": return res.stdout ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/a38/traversal.py0000644000177700017770000000166300000000000015241 0ustar00valhallavalhallafrom typing import Optional from contextlib import contextmanager 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=1551964381.0 a38-0.1.3/a38/trustedlist.py0000644000177700017770000002256600000000000015631 0ustar00valhallavalhallafrom typing import Dict import re from collections import defaultdict import xml.etree.ElementTree as ET import logging import base64 import subprocess from pathlib import Path from . import models from . import fields 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, "cryptography.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=1551964381.0 a38-0.1.3/a38/validation.py0000644000177700017770000000362500000000000015370 0ustar00valhallavalhallafrom typing import Optional, List, Sequence, Union from .traversal import Annotation, Traversal from . import fields 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=1608210741.0530627 a38-0.1.3/a38.egg-info/0000755000177700017770000000000000000000000014350 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1608210740.0 a38-0.1.3/a38.egg-info/PKG-INFO0000644000177700017770000002106200000000000015446 0ustar00valhallavalhallaMetadata-Version: 2.1 Name: a38 Version: 0.1.3 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 Description: # Python A38 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/export/fatturazione/it/normativa/f-2.htm) ## 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.fattura as 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) ``` # 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 for [privati](https://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/fatturaordinaria_v1.2.xsl) and [PA](https://www.fatturapa.gov.it/export/fatturazione/sdi/fatturapa/v1.2/fatturapa_v1.2.xsl) * From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip) # Copyright Copyright 2019 Truelite S.r.l. This software is released under the Apache License 2.0 Platform: UNKNOWN Requires-Python: >=3.6 Description-Content-Type: text/markdown Provides-Extra: cacerts Provides-Extra: formatted_python Provides-Extra: html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1608210740.0 a38-0.1.3/a38.egg-info/SOURCES.txt0000644000177700017770000000151500000000000016236 0ustar00valhallavalhalla.gitignore LICENSE MANIFEST.in README.md a38tool a38tool.md document-a38 download-docs publiccode.yml setup.cfg setup.py test-coverage a38/__init__.py a38/builder.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 debian/changelog debian/compat debian/control debian/copyright debian/python3-a38.docs debian/rules debian/source/format debian/source/options 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././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1608210740.0 a38-0.1.3/a38.egg-info/dependency_links.txt0000644000177700017770000000000100000000000020416 0ustar00valhallavalhalla ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1608210740.0 a38-0.1.3/a38.egg-info/requires.txt0000644000177700017770000000013200000000000016744 0ustar00valhallavalhallaasn1crypto python-dateutil pytz [cacerts] requests [formatted_python] yapf [html] lxml ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1608210740.0 a38-0.1.3/a38.egg-info/top_level.txt0000644000177700017770000000000400000000000017074 0ustar00valhallavalhallaa38 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1554372847.0 a38-0.1.3/a38tool0000755000177700017770000002422500000000000013507 0ustar00valhallavalhalla#!/usr/bin/python3 import argparse import logging import contextlib import sys import os.path import shutil from pathlib import Path import a38.fattura as a38 import xml.etree.ElementTree as ET log = logging.getLogger("a38tool") class Fail(Exception): pass class App: NAME = None def __init__(self, args): pass def load_fattura(self, pathname): if pathname.endswith(".p7m"): from a38.crypto import P7M p7m = P7M(pathname) return p7m.get_fattura() else: tree = ET.parse(pathname) return a38.auto_from_etree(tree.getroot()) @classmethod def add_subparser(cls, subparsers): parser = subparsers.add_parser(cls.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: 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): WRITE_MODE = None def __init__(self, args): super().__init__(args) self.files = args.files self.output = args.output @contextlib.contextmanager def open_output(self): if self.output is None: if "b" in self.WRITE_MODE: yield sys.stdout.buffer else: yield sys.stdout else: with open(self.output, self.WRITE_MODE) 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" WRITE_MODE = "wt" def __init__(self, args): super().__init__(args) if args.indent == "no": self.indent = None else: try: self.indent = int(args.indent) except ValueError: raise Fail("--indent argument must be an integer on 'no'") def write(self, f, out): import json json.dump(f.to_jsonable(), out, indent=self.indent) out.write("\n") @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 ExportXML(Exporter): """ output a fattura in XML """ NAME = "xml" WRITE_MODE = "wb" def write(self, f, out): tree = f.build_etree() tree.write(out) out.write(b"\n") @classmethod def add_subparser(cls, subparsers): parser = super().add_subparser(subparsers) return parser class ExportPython(Exporter): """ output a fattura as Python code """ NAME = "python" WRITE_MODE = "wt" def __init__(self, args): super().__init__(args) self.namespace = args.namespace if self.namespace == "": self.namespace = False self.unformatted = args.unformatted def get_code(self, f): code = f.to_python(namespace=self.namespace) if self.unformatted: return code try: from yapf.yapflib import yapf_api except ModuleNotFoundError: return code code, changed = yapf_api.FormatCode(code) return code def write(self, f, out): print(self.get_code(f), file=out) @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 Renderer(App): 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 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): 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) 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) ExportXML.add_subparser(subparsers) ExportPython.add_subparser(subparsers) Diff.add_subparser(subparsers) Validate.add_subparser(subparsers) RenderHTML.add_subparser(subparsers) RenderPDF.add_subparser(subparsers) UpdateCAPath.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=1551964381.0 a38-0.1.3/a38tool.md0000644000177700017770000001376500000000000014112 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 e 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=1608210741.0730631 a38-0.1.3/debian/0000755000177700017770000000000000000000000013505 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/changelog0000644000177700017770000000022600000000000015357 0ustar00valhallavalhallaa38 (0.1.0-1) UNRELEASED; urgency=low * Autogenerated by py2dsp v2.20180804 -- Enrico Zini Fri, 15 Feb 2019 09:19:09 +0000 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/compat0000644000177700017770000000000200000000000014703 0ustar00valhallavalhalla9 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/control0000644000177700017770000000224400000000000015112 0ustar00valhallavalhallaSource: a38 Section: python Priority: optional Maintainer: Enrico Zini Build-Depends: debhelper (>= 9), dh-python, python3-all, python3-dateutil, python3-tz, python3-asn1crypto, python3-lxml Standards-Version: 4.2.0 Package: python3-a38 Architecture: all Depends: ${misc:Depends}, ${python3:Depends}, python3-dateutil, python3-tz, python3-asn1crypto, Recommends: ${python3:Recommends}, python3-yapf, python3-lxml, wkhtmltopdf Suggests: ${python3:Suggests} Description: parse and generate Italian Fattura Elettronica 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/copyright0000644000177700017770000002712100000000000015443 0ustar00valhallavalhallaFormat: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: a38 Upstream-Contact: FIXME Source: Files: * Copyright: FIXME License: FIXME Files: debian/* Copyright: 2019 © Enrico Zini License: FIXME License: FIXME 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=1550662804.0 a38-0.1.3/debian/python3-a38.docs0000644000177700017770000000001200000000000016345 0ustar00valhallavalhallaREADME.md ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/rules0000755000177700017770000000013300000000000014562 0ustar00valhallavalhalla#! /usr/bin/make -f export PYBUILD_NAME=a38 %: dh $@ --with python3 --buildsystem=pybuild././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0770633 a38-0.1.3/debian/source/0000755000177700017770000000000000000000000015005 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/source/format0000644000177700017770000000001400000000000016213 0ustar00valhallavalhalla3.0 (quilt) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/debian/source/options0000644000177700017770000000004600000000000016423 0ustar00valhallavalhallaextend-diff-ignore="^[^/]+.egg-info/" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0810635 a38-0.1.3/doc/0000755000177700017770000000000000000000000013030 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/doc/.gitignore0000644000177700017770000000006300000000000015017 0ustar00valhallavalhalla/IT01234567890_FP*.xml /*.pdf /*.xls /*.xsd /*.xsl ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1551964381.0 a38-0.1.3/doc/README.md0000644000177700017770000000132000000000000014303 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: Assocons validator: Agenzia delle Entrate validator: # TODO Get documentation from (it covers Fattura Elettronica Semplificata) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/document-a380000755000177700017770000000630400000000000014423 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=1550662804.0 a38-0.1.3/download-docs0000755000177700017770000000601000000000000014743 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/export/fatturazione/it/normativa/f-2.htm") download("https://www.fatturapa.gov.it/export/fatturazione/it/b-3.htm") 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=1608202863.0 a38-0.1.3/publiccode.yml0000644000177700017770000000324500000000000015123 0ustar00valhallavalhallapubliccodeYmlVersion: '0.2' name: A38 url: 'https://github.com/Truelite/python-a38.git' softwareVersion: 0.1.3 releaseDate: '2019-03-07' 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.5' optional: false maintenance: type: internal contacts: - name: Truelite srl email: a38@truelite.it legal: license: Apache-2.0 mainCopyrightOwner: Enrico Zini 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0970638 a38-0.1.3/setup.cfg0000644000177700017770000000017600000000000014110 0ustar00valhallavalhalla[mypy] namespace_packages = True mypy_path = stubs [options] python_requires = >= 3.6 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1608202888.0 a38-0.1.3/setup.py0000755000177700017770000000144100000000000014000 0ustar00valhallavalhalla#!/usr/bin/env python3 from setuptools import setup with open("README.md", "r") as fp: long_description = fp.read() setup( name="a38", version="0.1.3", 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=["python-dateutil", "pytz", "asn1crypto"], test_requires=["python-dateutil", "pytz", "asn1crypto"], extras_require={ "formatted_python": ["yapf"], "html": ["lxml"], "cacerts": ["requests"], }, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0930638 a38-0.1.3/stubs/0000755000177700017770000000000000000000000013423 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/stubs/__init__.pyi0000644000177700017770000000000000000000000015673 0ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0930638 a38-0.1.3/stubs/dateutil/0000755000177700017770000000000000000000000015236 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/stubs/dateutil/__init__.pyi0000644000177700017770000000000000000000000017506 0ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/stubs/dateutil/parser.pyi0000644000177700017770000000010300000000000017247 0ustar00valhallavalhallaimport datetime def isoparse(arg: str) -> datetime.datetime: ... ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1550662804.0 a38-0.1.3/test-coverage0000755000177700017770000000020500000000000014756 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=1608210741.0930638 a38-0.1.3/tests/0000755000177700017770000000000000000000000013425 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1608210741.0930638 a38-0.1.3/tests/data/0000755000177700017770000000000000000000000014336 5ustar00valhallavalhalla././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1551964381.0 a38-0.1.3/tests/data/dati_trasporto.xml0000644000177700017770000001305300000000000020120 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 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=1551964381.0 a38-0.1.3/tests/data/test.txt.p7m0000644000177700017770000000532300000000000016563 0ustar00valhallavalhalla0  *H  0 1 0  `He0P *H CAThis is only a test payload. Questo è solo un payload di test. V0R0:{+ 0  *H  01 0 UIT10U INFOCERT SPA1"0 U Certificatore Accreditato10U 079452110061%0#U InfoCert Firma Qualificata 20 180509101915Z 210509000000Z010U.201571125522831 0 UIT10U non presente10UTINIT-ZNINRC76E03A785Z1 0 U ZINI10 U* ENRICO10U ZINI ENRICO0"0  *H 0 WmWTƖBLA(a05$tW^*5wspŎ|iYŘӰ&{RC 3E2sZ zه{D3,)tK8d 9pt[gIM4x&,^Q`{1g~RU@ fCjKbyYe?q`2c/~Tt4y+-8/2 d ;00 U00U0020.,http://crl.infocert.it/crls/firma2/CRL19.crl0ldap://ldap.infocert.it/cn%3DInfoCert%20Firma%20Qualificata%202%20CRL19,ou%3DCertificatore%20Accreditato,o%3DINFOCERT%20SPA,c%3DIT?certificateRevocationList0%U0firma.digitale@infocert.it0n+b0`0'+0http://ocsp.sc.infocert.it/05+0)http://cert.infocert.it/ca2/firma2/CA.crt0oU h0f0O+L$0E0C+7http://www.firma.infocert.it/documentazione/manuali.php0+L0 @0+x0v0F0F0 F0F0 F0>F0402,https://www.firma.infocert.it/pdf/PKI-DS.pdfEN0U@0 U0enrico@enricozini.org0(U !00+ 119760503000000Z0U#0! r՚ 8顁01 0 UIT10U INFOCERT SPA1"0 U Certificatore Accreditato10U 079452110061%0#U InfoCert Firma Qualificata 20Ug * 'pd0  *H  ~2ʫ6zZ=XsIe:RaG=z{!J^&2t䜧5ďܭ9Al!mA2s@_>,@k%n?l N4>9KD/( 'T('"wVCFK+nfi/òjߠB=NU觙H)U@їEqTDuwiBX(Ԝ&KY5 Vߎ', 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): def test_parse_dati_trasporto(self): import xml.etree.ElementTree as ET tree = ET.parse("tests/data/dati_trasporto.xml") a38.auto_from_etree(tree.getroot()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1551964381.0 a38-0.1.3/tests/test_fields.py0000644000177700017770000006531500000000000016316 0ustar00valhallavalhallafrom unittest import TestCase from a38 import fields from a38 import models from a38 import validation from a38.builder import Builder from a38.diff import Diff from decimal import Decimal import datetime import io 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.345")) 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")) 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 self.assert_validates(f, 1.1, result=Decimal("1.100000000000000088817841970012523233890533447265625"), errors=[ "sample: Decimal('1.100000000000000088817841970012523233890533447265625') 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.1", "2.2"), null=True) 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 dec11 = Decimal(1.1) self.assert_validates(f, 1.1, result=dec11, errors=[ "sample: {!r} is not a valid choice for this field".format(dec11), ]) 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_field_diff(f, "1.0001", "1.0002") def test_xml(self): f = self.get_field(null=True) self.assertEqual(self.to_xml(f, "12.345"), "12.34") 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=1550662804.0 a38-0.1.3/tests/test_models.py0000644000177700017770000000327700000000000016332 0ustar00valhallavalhallafrom unittest import TestCase from a38 import fields from a38 import 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_clean_value(self): val = Sample.clean_value(Sample1("foo", "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=1551964381.0 a38-0.1.3/tests/test_p7m.py0000644000177700017770000000753200000000000015550 0ustar00valhallavalhallafrom unittest import TestCase import tempfile from contextlib import contextmanager import os from a38.crypto import P7M, InvalidSignatureError, SignerCertificateError CA_CERT = """ -----BEGIN CERTIFICATE----- MIIFJjCCBA6gAwIBAgIBATANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMCSVQx FTATBgNVBAoMDElORk9DRVJUIFNQQTEiMCAGA1UECwwZQ2VydGlmaWNhdG9yZSBB Y2NyZWRpdGF0bzEUMBIGA1UEBRMLMDc5NDUyMTEwMDYxJTAjBgNVBAMMHEluZm9D ZXJ0IEZpcm1hIFF1YWxpZmljYXRhIDIwHhcNMTMwNDE5MTQyNjE1WhcNMjkwNDE5 MTUyNjE1WjCBhTELMAkGA1UEBhMCSVQxFTATBgNVBAoMDElORk9DRVJUIFNQQTEi MCAGA1UECwwZQ2VydGlmaWNhdG9yZSBBY2NyZWRpdGF0bzEUMBIGA1UEBRMLMDc5 NDUyMTEwMDYxJTAjBgNVBAMMHEluZm9DZXJ0IEZpcm1hIFF1YWxpZmljYXRhIDIw ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFoW5eA0k3AcU+/v2uKclE hGrxXlqOUptAQJLSjysP7IaKKtGxIeX8HNavxRaDkLkQNElql+t4GgIPyJk4lzHb H72c1Ls2SH06X7uCo5iGRH3+FU1ScbcrzviAPB+yeqUZ1cKkGyyGQ1wBsorxpREU eajkW2wsDiY/DYyeTG1I3ECOk/2sZW1U/xGWeVIlNCg9lkqrjvwu6swKVg3LPRiD L4Cqzsh4w9VZzJeDvKfer6lp/fRRduY5fSajtttgCoERrw0hZH/PkkmDCcnbLSjx 59Knu3jHip5prgGVU29MKANf573VZAAZfau/lAxf1K91DEXxtPWknEUULt3beGef AgMBAAGjggGdMIIBmTAPBgNVHRMBAf8EBTADAQH/MFgGA1UdIARRME8wTQYEVR0g ADBFMEMGCCsGAQUFBwIBFjdodHRwOi8vd3d3LmZpcm1hLmluZm9jZXJ0Lml0L2Rv Y3VtZW50YXppb25lL21hbnVhbGkucGhwMCUGA1UdEgQeMByBGmZpcm1hLmRpZ2l0 YWxlQGluZm9jZXJ0Lml0MIHVBgNVHR8Egc0wgcowgceggcSggcGGKmh0dHA6Ly9j cmwuaW5mb2NlcnQuaXQvY3Jscy9maXJtYTIvQVJMLmNybIaBkmxkYXA6Ly9sZGFw LmluZm9jZXJ0Lml0L2NuJTNESW5mb0NlcnQlMjBGaXJtYSUyMFF1YWxpZmljYXRh JTIwMixvdSUzRENlcnRpZmljYXRvcmUlMjBBY2NyZWRpdGF0byxvJTNESU5GT0NF UlQlMjBTUEEsYyUzRElUP2F1dGhvcml0eVJldm9jYXRpb25MaXN0MA4GA1UdDwEB /wQEAwIBBjAdBgNVHQ4EFgQUk90h/APQFQpyraPM1ZoJnTiLnekwDQYJKoZIhvcN AQELBQADggEBAJYdIAO8JCHr9dTT/kpy5AZpgo8XoIQW/q9tNQPwZkdd/bAfgLib olvbk7ZTsiVlVv35Bb9rhM58SKP1Xa9c26Cf8y4zhoplVbhfKRGVCLj1u1EXdPhC UQb8WWcM0AyLOXj3qhbMh77UL0K9eaRrwTAENbl43Jy65HPHubNnk9U9wIUUtLgR Hl5Oog1ZUSV5oLEkeSwzHyk5ZQnv24BzU9UXJ/amAt2ff1Krr3/PsY4Juwgtpg1N qq8tid5L+lN7qJ8xXfxMuUX2aWkWftCBL8H75U7NnYm/Zx6XyRaULFzCDw0RBSHa WGPH+t5X7ZMMERXn8Z/2LTYWuj9w1+WeieY= -----END CERTIFICATE----- """ CA_CERT_HASH = "af603d58.0" class TestAnagrafica(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") with self.capath() as capath: p7m.verify_signature(capath) def test_verify_corrupted_random(self): p7m = P7M("tests/data/test.txt.p7m") 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") 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.assertRaisesRegexp(InvalidSignatureError, r"routines:CMS_verify:content verify error"): p7m.verify_signature(capath) def test_verify_noca(self): p7m = P7M("tests/data/test.txt.p7m") with tempfile.TemporaryDirectory() as capath: with self.assertRaisesRegexp(InvalidSignatureError, r"Verify error:unable to get local issuer certificate"): p7m.verify_signature(capath)