a38-0.1.2/0000755000177700017770000000000013474174135013532 5ustar valhallavalhalla00000000000000a38-0.1.2/LICENSE0000644000177700017770000002613513433236224014537 0ustar valhallavalhalla00000000000000 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. a38-0.1.2/MANIFEST.in0000644000177700017770000000034613474172164015273 0ustar valhallavalhalla00000000000000include 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 a38-0.1.2/PKG-INFO0000644000177700017770000002103313474174135014626 0ustar valhallavalhalla00000000000000Metadata-Version: 2.1 Name: a38 Version: 0.1.2 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 Description-Content-Type: text/markdown Provides-Extra: cacerts Provides-Extra: formatted_python Provides-Extra: html a38-0.1.2/README.md0000644000177700017770000001510413454056004015002 0ustar valhallavalhalla00000000000000# 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 a38-0.1.2/a38/0000755000177700017770000000000013474174135014125 5ustar valhallavalhalla00000000000000a38-0.1.2/a38/__init__.py0000644000177700017770000000000013433236224016215 0ustar valhallavalhalla00000000000000a38-0.1.2/a38/builder.py0000644000177700017770000000500713433236224016120 0ustar valhallavalhalla00000000000000from 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) a38-0.1.2/a38/crypto.py0000644000177700017770000000627513440214335016017 0ustar valhallavalhalla00000000000000from 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) a38-0.1.2/a38/diff.py0000644000177700017770000000437413433236224015410 0ustar valhallavalhalla00000000000000from 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)) a38-0.1.2/a38/fattura.py0000644000177700017770000007621613440214335016147 0ustar valhallavalhalla00000000000000from . 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 a38-0.1.2/a38/fattura_semplificata.py0000644000177700017770000001434113440214335020657 0ustar valhallavalhalla00000000000000from .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) a38-0.1.2/a38/fields.py0000644000177700017770000005370113440214335015741 0ustar valhallavalhalla00000000000000from 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) 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 a38-0.1.2/a38/models.py0000644000177700017770000002247013440214335015755 0ustar valhallavalhalla00000000000000from 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) a38-0.1.2/a38/render.py0000644000177700017770000000257013440214335015750 0ustar valhallavalhalla00000000000000from 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 a38-0.1.2/a38/traversal.py0000644000177700017770000000166313433236224016501 0ustar valhallavalhalla00000000000000from 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) a38-0.1.2/a38/trustedlist.py0000644000177700017770000002256613440214335017066 0ustar valhallavalhalla00000000000000from 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) a38-0.1.2/a38/validation.py0000644000177700017770000000362513440214335016625 0ustar valhallavalhalla00000000000000from 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)) a38-0.1.2/a38.egg-info/0000755000177700017770000000000013474174135015617 5ustar valhallavalhalla00000000000000a38-0.1.2/a38.egg-info/PKG-INFO0000644000177700017770000002103313474174134016712 0ustar valhallavalhalla00000000000000Metadata-Version: 2.1 Name: a38 Version: 0.1.2 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 Description-Content-Type: text/markdown Provides-Extra: cacerts Provides-Extra: formatted_python Provides-Extra: html a38-0.1.2/a38.egg-info/SOURCES.txt0000644000177700017770000000121213474174135017477 0ustar valhallavalhalla00000000000000LICENSE MANIFEST.in README.md a38tool document-a38 download-docs 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 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.p7ma38-0.1.2/a38.egg-info/dependency_links.txt0000644000177700017770000000000113474174134021664 0ustar valhallavalhalla00000000000000 a38-0.1.2/a38.egg-info/requires.txt0000644000177700017770000000013213474174134020212 0ustar valhallavalhalla00000000000000asn1crypto python-dateutil pytz [cacerts] requests [formatted_python] yapf [html] lxml a38-0.1.2/a38.egg-info/top_level.txt0000644000177700017770000000000413474174134020342 0ustar valhallavalhalla00000000000000a38 a38-0.1.2/a38tool0000755000177700017770000002422513451354357014757 0ustar valhallavalhalla00000000000000#!/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") a38-0.1.2/doc/0000755000177700017770000000000013474174135014277 5ustar valhallavalhalla00000000000000a38-0.1.2/doc/README.md0000644000177700017770000000132013440214335015540 0ustar valhallavalhalla00000000000000Run 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) a38-0.1.2/document-a380000755000177700017770000000630413433236224015663 0ustar valhallavalhalla00000000000000#!/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") a38-0.1.2/download-docs0000755000177700017770000000601013433236224016203 0ustar valhallavalhalla00000000000000#!/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") a38-0.1.2/setup.cfg0000644000177700017770000000013213474174135015347 0ustar valhallavalhalla00000000000000[mypy] namespace_packages = True mypy_path = stubs [egg_info] tag_build = tag_date = 0 a38-0.1.2/setup.py0000755000177700017770000000144113474173716015253 0ustar valhallavalhalla00000000000000#!/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.2", 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"], }, ) a38-0.1.2/stubs/0000755000177700017770000000000013474174135014672 5ustar valhallavalhalla00000000000000a38-0.1.2/stubs/__init__.pyi0000644000177700017770000000000013433236224017133 0ustar valhallavalhalla00000000000000a38-0.1.2/stubs/dateutil/0000755000177700017770000000000013474174135016505 5ustar valhallavalhalla00000000000000a38-0.1.2/stubs/dateutil/__init__.pyi0000644000177700017770000000000013433236224020746 0ustar valhallavalhalla00000000000000a38-0.1.2/stubs/dateutil/parser.pyi0000644000177700017770000000010313433236224020507 0ustar valhallavalhalla00000000000000import datetime def isoparse(arg: str) -> datetime.datetime: ... a38-0.1.2/test-coverage0000755000177700017770000000020513433236224016216 0ustar valhallavalhalla00000000000000#!/bin/sh set -u if [ $# -eq 0 ] then set -x eatmydata nose2-3 -C --coverage-report html else set -x eatmydata nose2-3 "$@" fi a38-0.1.2/tests/0000755000177700017770000000000013474174135014674 5ustar valhallavalhalla00000000000000a38-0.1.2/tests/data/0000755000177700017770000000000013474174135015605 5ustar valhallavalhalla00000000000000a38-0.1.2/tests/data/dati_trasporto.xml0000644000177700017770000001305313440214335021355 0ustar valhallavalhalla00000000000000 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 a38-0.1.2/tests/data/test.txt.p7m0000644000177700017770000000532313440214335020020 0ustar valhallavalhalla000000000000000  *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()) a38-0.1.2/tests/test_fields.py0000644000177700017770000006531513440214335017553 0ustar valhallavalhalla00000000000000from 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") a38-0.1.2/tests/test_models.py0000644000177700017770000000327713433236224017572 0ustar valhallavalhalla00000000000000from 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) a38-0.1.2/tests/test_p7m.py0000644000177700017770000000753213440214335017005 0ustar valhallavalhalla00000000000000from 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)