././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3254445
a38-0.1.8/ 0000755 0001777 0001777 00000000000 14547515512 012340 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3174443
a38-0.1.8/.github/ 0000755 0001777 0001777 00000000000 14547515512 013700 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3214443
a38-0.1.8/.github/workflows/ 0000755 0001777 0001777 00000000000 14547515512 015735 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/.github/workflows/py.yml 0000644 0001777 0001777 00000001146 14373374076 017117 0 ustar 00valhalla valhalla ---
name: Python A38
on:
push:
branches:
- master
pull_request:
branches:
- master
# invoke the pipeline manually
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Work around Apt caching issues
run: make ci-workaround
- name: Install OS packages
run: make install-os
- name: Install Pip packages
run: make install-py
- name: Lint
run: make lint
- name: Run the tests
run: make test
- name: Install the package
run: make install-package
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/.gitignore 0000644 0001777 0001777 00000000122 14373374076 014330 0 ustar 00valhalla valhalla *.swp
*.pyc
/.mypy_cache
/MANIFEST
/.coverage
/build
/dist
/htmlcov
a38.egg-info/
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704893140.0
a38-0.1.8/CHANGELOG.md 0000644 0001777 0001777 00000003225 14547515324 014154 0 ustar 00valhalla valhalla # New in version UNRELEASED
* Fix tests for Python 3.12
New in version 0.1.7
* Allow ranges of decimal digits in some fields, to match specifications more
closely
* Added `allegati` subcommand to list and extract attachments. See #32
* Allow more than one DatiRitenuta tag in DatiGeneraliDocumento, thanks
@tschager, see #38
* Bump minimum supported Python version to 3.11
# New in version 0.1.6
* Generate `dati_riepilogo` with properly set `natura` (#27)
* Ignore non-significant digits when computing differences between Decimal fields
* a38tool diff: return exit code 0 if there are no differences
* Change Prezzo Unitario decimals precision to 3 digits, thanks @matteorizzello
* Fixed a rounding issue (#35), thanks @tschager
* Updated signature in test certificate
# New in version 0.1.5
* Added to `a38.codec` has a basic implementation of interactive editing in a
text editor
# New in version 0.1.4
* When a Model instance is required, allow to pass a dict matching the Model
fields instead
* `natura_iva` is now from 2 to 4 characters long (#18)
* Added a38.consts module with constants for common enumerations (#18)
* Added `DettaglioLinee.autofill_prezzo_totale`
* Export `a38.fattura.$MODEL` models as `a38.$MODEL`
* Implemented `a38tool yaml` to export in YAML format
* Implemented loading from YAML and JSON as if they were XML
* Implemented `a38tool edit` to open a fattura in a text editor using YAML or
Python formats (#22)
* Use UTF-8 encoding and include xml declaration when writing XML from a38tool
(#19)
* New module `a38.codec`, with functions to load and save from/to all supported
formats
* Use defusedxml for parsing if available (#24)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/LICENSE 0000644 0001777 0001777 00000026135 14373374076 013361 0 ustar 00valhalla valhalla 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.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676542731.0
a38-0.1.8/MANIFEST.in 0000644 0001777 0001777 00000000402 14373401413 014061 0 ustar 00valhalla valhalla include MANIFEST.in
include LICENSE
include README.md
include doc/README.md
include tests/*.py
include tests/data/*
include stubs/*.pyi
include stubs/dateutil/*.pyi
include download-docs
include document-a38
include test-coverage
include requirements-lib.txt ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/Makefile 0000644 0001777 0001777 00000002152 14373374076 014005 0 ustar 00valhalla valhalla ci-workaround:
sudo sed -i 's/azure\.//' /etc/apt/sources.list
sudo apt-get -o Acquire::Retries=5 update
install-os:
sudo apt-get -o Acquire::Retries=5 install \
openssl \
wkhtmltopdf \
eatmydata \
python3-nose2
install-py:
pip install -r requirements-lib.txt
pip install -r requirements-devops.txt
test:
sh test-coverage
install-package:
pip install .
clean:
rm --recursive --force \
$(PWD)/build \
$(PWD)/dist \
$(PWD)/htmlcov \
$(PWD)/a38.egg-info \
$(PWD)/.coverage
lint:
isort \
--check \
$(PWD)/a38 \
$(PWD)/tests \
setup.py
flake8 \
--ignore=E126,E203,E501,W503 \
--max-line-length 120 \
--indent-size 4 \
--jobs=8 \
$(PWD)/a38 \
$(PWD)/tests \
setup.py
bandit \
--recursive \
--number=3 \
-lll \
-iii \
$(PWD)/a38 \
$(PWD)/tests \
setup.py
lint-dev:
isort \
--atomic \
$(PWD)/a38 \
$(PWD)/tests \
setup.py
$(eval PIP_DEPS=$(shell awk '{printf("%s,",$$1)}' requirements-lib.txt | sed '$$s/,$$//'))
autoflake \
--imports=$(PIP_DEPS) \
--recursive \
--in-place \
--remove-unused-variables \
$(PWD)/a38 \
$(PWD)/tests \
setup.py
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3254445
a38-0.1.8/PKG-INFO 0000644 0001777 0001777 00000016073 14547515512 013444 0 ustar 00valhalla valhalla Metadata-Version: 2.1
Name: a38
Version: 0.1.8
Summary: parse and generate Italian Fattura Elettronica
Home-page: https://github.com/Truelite/python-a38/
Author: Enrico Zini
Author-email: enrico@truelite.it
License: https://www.apache.org/licenses/LICENSE-2.0.html
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: cacerts
Provides-Extra: formatted_python
Provides-Extra: html
License-File: LICENSE
# Python A38

Library to generate Italian Fattura Elettronica from Python.
This library implements a declarative data model similar to Django models, that
is designed to describe, validate, serialize and parse Italian Fattura
Elettronica data.
Only part of the specification is implemented, with more added as needs will
arise. You are welcome to implement the missing pieces you need and send a pull
request: the idea is to have a good, free (as in freedom) library to make
billing in Italy with Python easier for everyone.
The library can generate various kinds of fatture that pass validation, and can
parse all the example XML files distributed by
[fatturapa.gov.it](https://www.fatturapa.gov.it/it/lafatturapa/esempi/)
## Dependencies
Required: dateutil, pytz, asn1crypto, and the python3 standard library.
Optional:
* yapf for formatting `a38tool python` output
* lxml for rendering to HTML
* the wkhtmltopdf command for rendering to PDF
* requests for downloading CA certificates for signature verification
## `a38tool` script
A simple command line wrapper to the library functions is available as `a38tool`:
```text
$ a38tool --help
usage: a38tool [-h] [--verbose] [--debug]
{json,xml,python,diff,validate,html,pdf,update_capath} ...
Handle fattura elettronica files
positional arguments:
{json,xml,python,diff,validate,html,pdf,update_capath}
actions
json output a fattura in JSON
xml output a fattura in XML
python output a fattura as Python code
diff show the difference between two fatture
validate validate the contents of a fattura
html render a Fattura as HTML using a .xslt stylesheet
pdf render a Fattura as PDF using a .xslt stylesheet
update_capath create/update an openssl CApath with CA certificates
that can be used to validate digital signatures
optional arguments:
-h, --help show this help message and exit
--verbose, -v verbose output
--debug debug output
```
See [a38tool.md](a38tool.md) for more details.
## Example code
```py
import a38
from a38.validation import Validation
import datetime
import sys
cedente_prestatore = a38.CedentePrestatore(
a38.DatiAnagraficiCedentePrestatore(
a38.IdFiscaleIVA("IT", "01234567890"),
codice_fiscale="NTNBLN22C23A123U",
anagrafica=a38.Anagrafica(denominazione="Test User"),
regime_fiscale="RF01",
),
a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"),
iscrizione_rea=a38.IscrizioneREA(
ufficio="FI",
numero_rea="123456",
stato_liquidazione="LN",
),
contatti=a38.Contatti(email="local_part@pec_domain.it"),
)
cessionario_committente = a38.CessionarioCommittente(
a38.DatiAnagraficiCessionarioCommittente(
a38.IdFiscaleIVA("IT", "76543210987"),
anagrafica=a38.Anagrafica(denominazione="A Company SRL"),
),
a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"),
)
bill_number = 1
f = a38.FatturaPrivati12()
f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561")
f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF"
f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore
f.fattura_elettronica_header.cessionario_committente = cessionario_committente
body = f.fattura_elettronica_body[0]
body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento(
tipo_documento="TD01",
divisa="EUR",
data=datetime.date.today(),
numero=bill_number,
causale=["Test billing"],
)
body.dati_beni_servizi.add_dettaglio_linee(
descrizione="Test item", quantita=2, unita_misura="kg",
prezzo_unitario="25.50", aliquota_iva="22.00")
body.dati_beni_servizi.add_dettaglio_linee(
descrizione="Other item", quantita=1, unita_misura="kg",
prezzo_unitario="15.50", aliquota_iva="22.00")
body.dati_beni_servizi.build_dati_riepilogo()
body.build_importo_totale_documento()
res = Validation()
f.validate(res)
if res.warnings:
for w in res.warnings:
print(str(w), file=sys.stderr)
if res.errors:
for e in res.errors:
print(str(e), file=sys.stderr)
filename = "{}{}_{:05d}.xml".format(
f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese,
f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice,
bill_number)
tree = f.build_etree()
with open(filename, "wb") as out:
tree.write(out, encoding="utf-8", xml_declaration=True)
```
# Digital signatures
Digital signatures on Firma Elettronica are
[CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures.
openssl cal verify the signatures, but not yet generate them. A patch to sign
with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0)
but not yet released as of 2019-02-26.
## Downloading CA certificates
CA certificates for validating digital certificates are
[distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature).
See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati).
There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but
apparently no way of getting a simple bundle of certificates useable by
openssl.
`a38tool` has basic features to download and parse CA certificate information,
and maintain a CA certificate directory:
```
a38tool update_capath certdir/ --remove-old
```
No particular effort is made to validate the downloaded certificates, besides
the standard HTTPS checks performed by the [requests
library](http://docs.python-requests.org/en/master/).
## Verifying signed `.p7m` files
Once you have a CA certificate directory, verifying signed p7m files is quite
straightforward:
```
openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/
```
# Useful links
XSLT stylesheets for displaying fatture:
* From [fatturapa.gov.it](https://www.fatturapa.gov.it/),
among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html)
* From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip)
# Copyright
Copyright 2019-2024 Truelite S.r.l.
This software is released under the Apache License 2.0
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704886099.0
a38-0.1.8/README.md 0000644 0001777 0001777 00000015215 14547477523 013634 0 ustar 00valhalla valhalla # 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/it/lafatturapa/esempi/)
## Dependencies
Required: dateutil, pytz, asn1crypto, and the python3 standard library.
Optional:
* yapf for formatting `a38tool python` output
* lxml for rendering to HTML
* the wkhtmltopdf command for rendering to PDF
* requests for downloading CA certificates for signature verification
## `a38tool` script
A simple command line wrapper to the library functions is available as `a38tool`:
```text
$ a38tool --help
usage: a38tool [-h] [--verbose] [--debug]
{json,xml,python,diff,validate,html,pdf,update_capath} ...
Handle fattura elettronica files
positional arguments:
{json,xml,python,diff,validate,html,pdf,update_capath}
actions
json output a fattura in JSON
xml output a fattura in XML
python output a fattura as Python code
diff show the difference between two fatture
validate validate the contents of a fattura
html render a Fattura as HTML using a .xslt stylesheet
pdf render a Fattura as PDF using a .xslt stylesheet
update_capath create/update an openssl CApath with CA certificates
that can be used to validate digital signatures
optional arguments:
-h, --help show this help message and exit
--verbose, -v verbose output
--debug debug output
```
See [a38tool.md](a38tool.md) for more details.
## Example code
```py
import a38
from a38.validation import Validation
import datetime
import sys
cedente_prestatore = a38.CedentePrestatore(
a38.DatiAnagraficiCedentePrestatore(
a38.IdFiscaleIVA("IT", "01234567890"),
codice_fiscale="NTNBLN22C23A123U",
anagrafica=a38.Anagrafica(denominazione="Test User"),
regime_fiscale="RF01",
),
a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"),
iscrizione_rea=a38.IscrizioneREA(
ufficio="FI",
numero_rea="123456",
stato_liquidazione="LN",
),
contatti=a38.Contatti(email="local_part@pec_domain.it"),
)
cessionario_committente = a38.CessionarioCommittente(
a38.DatiAnagraficiCessionarioCommittente(
a38.IdFiscaleIVA("IT", "76543210987"),
anagrafica=a38.Anagrafica(denominazione="A Company SRL"),
),
a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"),
)
bill_number = 1
f = a38.FatturaPrivati12()
f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561")
f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF"
f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore
f.fattura_elettronica_header.cessionario_committente = cessionario_committente
body = f.fattura_elettronica_body[0]
body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento(
tipo_documento="TD01",
divisa="EUR",
data=datetime.date.today(),
numero=bill_number,
causale=["Test billing"],
)
body.dati_beni_servizi.add_dettaglio_linee(
descrizione="Test item", quantita=2, unita_misura="kg",
prezzo_unitario="25.50", aliquota_iva="22.00")
body.dati_beni_servizi.add_dettaglio_linee(
descrizione="Other item", quantita=1, unita_misura="kg",
prezzo_unitario="15.50", aliquota_iva="22.00")
body.dati_beni_servizi.build_dati_riepilogo()
body.build_importo_totale_documento()
res = Validation()
f.validate(res)
if res.warnings:
for w in res.warnings:
print(str(w), file=sys.stderr)
if res.errors:
for e in res.errors:
print(str(e), file=sys.stderr)
filename = "{}{}_{:05d}.xml".format(
f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese,
f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice,
bill_number)
tree = f.build_etree()
with open(filename, "wb") as out:
tree.write(out, encoding="utf-8", xml_declaration=True)
```
# Digital signatures
Digital signatures on Firma Elettronica are
[CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures.
openssl cal verify the signatures, but not yet generate them. A patch to sign
with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0)
but not yet released as of 2019-02-26.
## Downloading CA certificates
CA certificates for validating digital certificates are
[distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature).
See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati).
There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but
apparently no way of getting a simple bundle of certificates useable by
openssl.
`a38tool` has basic features to download and parse CA certificate information,
and maintain a CA certificate directory:
```
a38tool update_capath certdir/ --remove-old
```
No particular effort is made to validate the downloaded certificates, besides
the standard HTTPS checks performed by the [requests
library](http://docs.python-requests.org/en/master/).
## Verifying signed `.p7m` files
Once you have a CA certificate directory, verifying signed p7m files is quite
straightforward:
```
openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/
```
# Useful links
XSLT stylesheets for displaying fatture:
* From [fatturapa.gov.it](https://www.fatturapa.gov.it/),
among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html)
* From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip)
# Copyright
Copyright 2019-2024 Truelite S.r.l.
This software is released under the Apache License 2.0
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3214443
a38-0.1.8/a38/ 0000755 0001777 0001777 00000000000 14547515512 012733 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/__init__.py 0000644 0001777 0001777 00000000037 14373374076 015051 0 ustar 00valhalla valhalla from .fattura import * # noqa
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/builder.py 0000644 0001777 0001777 00000005141 14373374076 014741 0 ustar 00valhalla valhalla # This module builds XML trees but does not parse them, so it does not need
# defusedxml
import xml.etree.ElementTree as ET
from contextlib import contextmanager
try:
import lxml.etree
HAVE_LXML = True
except ModuleNotFoundError:
HAVE_LXML = False
class Builder:
def __init__(self, etreebuilder=None):
if etreebuilder is None:
etreebuilder = ET.TreeBuilder()
self.etreebuilder = etreebuilder
self.default_namespace = None
def _decorate_tag_name(self, tag: str):
if self.default_namespace is not None and not tag.startswith("{"):
return "{" + self.default_namespace + "}" + tag
return tag
def add(self, tag: str, value: str, **attrs):
tag = self._decorate_tag_name(tag)
self.etreebuilder.start(tag, attrs)
if value is not None:
self.etreebuilder.data(value)
self.etreebuilder.end(tag)
@contextmanager
def element(self, tag: str, **attrs):
tag = self._decorate_tag_name(tag)
self.etreebuilder.start(tag, attrs)
yield self
self.etreebuilder.end(tag)
@contextmanager
def override_default_namespace(self, ns):
b = Builder(self.etreebuilder)
b.default_namespace = ns
yield b
def get_tree(self):
root = self.etreebuilder.close()
return ET.ElementTree(root)
if HAVE_LXML:
class LXMLBuilder:
def __init__(self, etreebuilder=None):
if etreebuilder is None:
etreebuilder = lxml.etree.TreeBuilder()
self.etreebuilder = etreebuilder
self.default_namespace = None
def _decorate_tag_name(self, tag: str):
if self.default_namespace is not None and not tag.startswith("{"):
return "{" + self.default_namespace + "}" + tag
return tag
def add(self, tag: str, value: str, **attrs):
tag = self._decorate_tag_name(tag)
self.etreebuilder.start(tag, attrs)
if value is not None:
self.etreebuilder.data(value)
self.etreebuilder.end(tag)
@contextmanager
def element(self, tag: str, **attrs):
tag = self._decorate_tag_name(tag)
self.etreebuilder.start(tag, attrs)
yield self
self.etreebuilder.end(tag)
@contextmanager
def override_default_namespace(self, ns):
b = Builder(self.etreebuilder)
b.default_namespace = ns
yield b
def get_tree(self):
root = self.etreebuilder.close()
return lxml.etree.ElementTree(root)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704882565.0
a38-0.1.8/a38/codec.py 0000644 0001777 0001777 00000023607 14547470605 014375 0 ustar 00valhalla valhalla from __future__ import annotations
import io
import json
import logging
import os
import subprocess
import tempfile
try:
from defusedxml import ElementTree as ET
except ModuleNotFoundError:
import xml.etree.ElementTree as ET
from typing import (Any, BinaryIO, Dict, List, Optional, Sequence, TextIO,
Type, Union)
try:
import ruamel.yaml
yaml = None
except ModuleNotFoundError:
ruamel = None
try:
import yaml
except ModuleNotFoundError:
yaml = None
from . import crypto
from .fattura import auto_from_dict, auto_from_etree
from .models import Model
log = logging.getLogger("codec")
if ruamel is not None:
def _load_yaml(fd: TextIO):
yaml_loader = ruamel.yaml.YAML(typ="safe", pure=True)
return yaml_loader.load(fd)
def _write_yaml(data: Dict[str, Any], file: TextIO):
yaml = ruamel.yaml.YAML(typ="safe")
yaml.default_flow_style = False
yaml.allow_unicode = True
yaml.explicit_start = True
yaml.dump(data, file)
elif yaml is not None:
def _load_yaml(fd: TextIO):
return yaml.load(fd, Loader=yaml.CLoader)
def _write_yaml(data: Dict[str, Any], file: TextIO):
yaml.dump(
data, stream=file, default_flow_style=False, sort_keys=False,
allow_unicode=True, explicit_start=True, Dumper=yaml.CDumper)
else:
def _load_yaml(fd: TextIO):
raise NotImplementedError("loading YAML requires ruamel.yaml or PyYAML to be installed")
def _write_yaml(data: Dict[str, Any], file: TextIO):
raise NotImplementedError("writing YAML requires ruamel.yaml or PyYAML to be installed")
class Codec:
"""
Base class for format-specific reading and writing of fatture
"""
# If True, file objects are expected to be open in binary mode
binary = False
def load(
self,
pathname: str,
model: Optional[Type[Model]]) -> Model:
"""
Load a fattura from a file.
If model is provided it will be used for loading, otherwise the Model
type will be autodetected
"""
raise NotImplementedError(f"{self.__class__.__name__}.load is not implemented")
def write_file(self, f: Model, file: Union[TextIO, BinaryIO]):
"""
Write a fattura to the given file deescriptor.
"""
raise NotImplementedError(f"{self.__class__.__name__}.write_file is not implemented")
def save(self, f: Model, pathname: str):
"""
Write a fattura to the given file
"""
with open(pathname, "wb" if self.binary else "wt") as fd:
self.write_file(f, fd)
def interactive_edit(self, f: Model) -> Optional[Model]:
"""
Edit the given model in an interactive editor, using the format of this
codec
"""
with io.StringIO() as orig:
self.write_file(f, orig)
return self.edit_buffer(orig.getvalue(), model=f.__class__)
def edit_buffer(self, buf: str, model: Optional[Type[Model]] = None) -> Optional[Model]:
"""
Open an editor on buf and return the edited fattura.
Return None if editing did not change the contents.
"""
editor = os.environ.get("EDITOR", "sensible-editor")
current = buf
error = None
while True:
with tempfile.NamedTemporaryFile(
mode="wt",
suffix=f".{self.EXTENSIONS[0]}") as tf:
# Write out the current buffer
tf.write(current)
if error is not None:
tf.write(f"# ERROR: {error}")
error = None
tf.flush()
# Run the editor on it
subprocess.run([editor, tf.name], check=True)
# Reopen by name in case the editor did not write on the same
# inode
with open(tf.name, "rt") as fd:
lines = []
for line in fd:
if line.startswith("# ERROR: "):
continue
lines.append(line)
edited = "".join(lines)
if edited == current:
return None
try:
return self.load(tf.name, model=model)
except Exception as e:
log.error("%s: cannot load edited file: %s", tf.name, e)
error = str(e)
class P7M(Codec):
"""
P7M codec, that only supports loading
"""
EXTENSIONS = ("p7m",)
def load(
self,
pathname: str,
model: Optional[Type[Model]] = None) -> Model:
p7m = crypto.P7M(pathname)
return p7m.get_fattura()
class JSON(Codec):
"""
JSON codec.
`indent` represents the JSON structure indentation, and can be None to
output everything in a single line.
`end` is a string that gets appended to the JSON structure.
"""
EXTENSIONS = ("json",)
def __init__(self, indent: Optional[int] = 1, end="\n"):
self.indent = indent
self.end = end
def load(
self,
pathname: str,
model: Optional[Type[Model]] = None) -> Model:
with open(pathname, "rt") as fd:
data = json.load(fd)
if model:
return model(**data)
else:
return auto_from_dict(data)
def write_file(self, f: Model, file: TextIO):
json.dump(f.to_jsonable(), file, indent=self.indent)
if self.end is not None:
file.write(self.end)
class YAML(Codec):
"""
YAML codec
"""
EXTENSIONS = ("yaml", "yml")
def load(
self,
pathname: str,
model: Optional[Type[Model]] = None) -> Model:
with open(pathname, "rt") as fd:
data = _load_yaml(fd)
if model:
return model(**data)
else:
return auto_from_dict(data)
def write_file(self, f: Model, file: TextIO):
_write_yaml(f.to_jsonable(), file)
class Python(Codec):
"""
Python codec.
`namespace` defines what namespace is used to refer to `a38` models. `None`
means use a default, `False` means not to use a namespace, a string defines
which namespace to use.
`unformatted` can be set to True to skip code formatting.
The code will be written with just the expression to build the fattura.
The code assumes `import datetime` and `from decimal import Decimal`.
If loadable is True, the file is written as a Python source that
creates a `fattura` variable with the fattura, with all the imports that
are needed. This generates a python file that can be loaded with load().
Note that loading Python fatture executes arbitrary Python code!
"""
EXTENSIONS = ("py",)
def __init__(
self, namespace: Union[None, bool, str] = "a38",
unformatted: bool = False,
loadable: bool = False):
self.namespace = namespace
self.unformatted = unformatted
self.loadable = loadable
def load(
self,
pathname: str,
model: Optional[Type[Model]] = None) -> Model:
with open(pathname, "rt") as fd:
code = compile(fd.read(), pathname, 'exec')
loc = {}
exec(code, {}, loc)
return loc["fattura"]
def write_file(self, f: Model, file: TextIO):
code = f.to_python(namespace=self.namespace)
if not self.unformatted:
try:
from yapf.yapflib import yapf_api
except ModuleNotFoundError:
pass
else:
code, changed = yapf_api.FormatCode(code)
if self.loadable:
print("import datetime", file=file)
print("from decimal import Decimal", file=file)
if self.namespace:
print("import", self.namespace, file=file)
elif self.namespace is False:
print("from a38.fattura import *", file=file)
else:
print("import a38", file=file)
print(file=file)
print("fattura = ", file=file, end="")
print(code, file=file)
class XML(Codec):
"""
XML codec
"""
EXTENSIONS = ("xml",)
binary = True
def load(
self,
pathname: str,
model: Optional[Type[Model]] = None) -> Model:
tree = ET.parse(pathname)
return auto_from_etree(tree.getroot())
def write_file(self, f: Model, file: BinaryIO):
tree = f.build_etree()
tree.write(file, encoding="utf-8", xml_declaration=True)
file.write(b"\n")
class Codecs:
"""
A collection of codecs
"""
ALL_CODECS = (XML, P7M, JSON, YAML, Python)
def __init__(
self,
include: Optional[Sequence[Type[Codec]]] = None,
exclude: Optional[Sequence[Type[Codec]]] = (Python,)):
"""
if `include` is not None, only codecs in that list are used.
If `exclude` is not None, all codecs are used except the given one.
If neither `include` nor `exclude` are None, all codecs are used.
By default, `exclude` is not None but it is set to exclude Python.
"""
self.codecs: List[Type[Codec]]
if include is not None and exclude is not None:
raise ValueError("include and exclude cannot both be set")
elif include is not None:
self.codecs = list(include)
elif exclude is not None:
self.codecs = [c for c in self.ALL_CODECS if c not in exclude]
else:
self.codecs = list(self.ALL_CODECS)
def codec_from_filename(self, pathname: str) -> Type[Codec]:
"""
Infer a Codec class from the extension of the file at `pathname`.
"""
ext = pathname.rsplit(".", 1)[1].lower()
for c in self.codecs:
if ext in c.EXTENSIONS:
return c
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/consts.py 0000644 0001777 0001777 00000017724 14373374076 014636 0 ustar 00valhalla valhalla # see table at page 26 for the PDF document
# GUIDA ALLA COMPILAZIONE DELLE FATTURE ELETTRONICHE E DELL’ESTEROMETRO
# from AdE (Agenzia Delle Entrate), version 1.6 - 2022/02/04
# https://www.agenziaentrate.gov.it/portale/documents/20143/451259/Guida_compilazione-FE_2021_07_07.pdf/e6fcdd04-a7bd-e6f2-ced4-cac04403a768
# see also:
# - https://agenziaentrate.gov.it/portale/documents/20143/296703/Variazioni+alle+specifiche+tecniche+fatture+elettroniche2021-07-02.pdf # noqa
# - https://www.agenziaentrate.gov.it/portale/web/guest/schede/comunicazioni/fatture-e-corrispettivi/faq-fe/risposte-alle-domande-piu-frequenti-categoria/compilazione-della-fattura-elettronica # noqa
NATURA_IVA = (
"N1",
"N2",
"N2.1", # non soggette ad IVA ai sensi degli artt. da 7 a 7-septies del D.P.R. n. 633/72
"N2.2", # non soggette - altri casi
"N3",
"N3.1", # non imponibili - esportazioni
"N3.2", # non imponibili - cessioni intracomunitarie
"N3.3", # non imponibili - cessioni verso San Marino
"N3.4", # non imponibili - operazioni assimilate alle cessioni all'esportazione
"N3.5", # non imponibili - a seguito di dichiarazioni d'intento
"N3.6", # non imponibili - altre operazioni
"N4",
"N5",
"N6",
"N6.1", # inversione contabile - cessione di rottami e altri materiali di recupero
"N6.2", # inversione contabile – cessione di oro e argento ai sensi della
# legge 7/2000 nonché di oreficeria usata ad OPO
"N6.3", # inversione contabile - subappalto nel settore edile
"N6.4", # inversione contabile - cessione di fabbricati
"N6.5", # inversione contabile - cessione di telefoni cellulari
"N6.6", # inversione contabile - cessione di prodotti elettronici
"N6.7", # inversione contabile - prestazioni comparto edile e settori connessi
"N6.8", # inversione contabile - operazioni settore energetico
"N6.9", # inversione contabile - altri casi
"N7",
)
# see pages 1 to 25 for the PDF document
# GUIDA ALLA COMPILAZIONE DELLE FATTURE ELETTRONICHE E DELL’ESTEROMETRO
# from AdE (Agenzia Delle Entrate), version 1.6 - 2022/02/04
# https://www.agenziaentrate.gov.it/portale/documents/20143/451259/Guida_compilazione-FE_2021_07_07.pdf/e6fcdd04-a7bd-e6f2-ced4-cac04403a768
TIPO_DOCUMENTO = (
"TD01", # FATTURA
"TD02", # ACCONTO/ANTICIPO SU FATTURA
"TD03", # ACCONTO/ANTICIPO SU PARCELLA
"TD04", # NOTA DI CREDITO
"TD05", # NOTA DI DEBITO
"TD06", # PARCELLA
"TD07", # FATTURA SEMPLIFICATA
"TD08", # NOTA DI CREDITO SEMPLIFICATA
"TD09", # NOTA DI DEBITO SEMPLIFICATA
"TD16", # INTEGRAZIONE FATTURA DA REVERSE CHARGE INTERNO
"TD17", # INTEGRAZIONE/AUTOFATTURA PER ACQUISTO SERVIZI DALL'ESTERO
"TD18", # INTEGRAZIONE PER ACQUISTO DI BENI INTRACOMUNITARI
"TD19", # INTEGRAZIONE/AUTOFATTURA PER ACQUISTO DI BENI EX ART. 17 C.2 D.P.R. 633/72
"TD20", # AUTOFATTURA PER REGOLARIZZAZIONE E INTEGRAZIONE DELLE FATTURE
# (EX ART. 6 COMMI 8 E 9-BIS D. LGS. 471/97 O ART. 46 C.5 D.L. 331/93)
"TD21", # AUTOFATTURA PER SPLAFONAMENTO
"TD22", # ESTRAZIONE BENI DA DEPOSITO IVA
"TD23", # ESTRAZIONE BENI DA DEPOSITO IVA CON VERSAMENTO DELL'IVA
"TD24", # FATTURA DIFFERITA DI CUI ALL'ART. 21, COMMA 4, TERZO PERIODO, LETT. A), DEL D.P.R. N. 633/72
"TD25", # FATTURA DIFFERITA DI CUI ALL'ART. 21, COMMA 4, TERZO PERIODO LETT. B), DEL D.P.R. N. 633/72
"TD26", # CESSIONE DI BENI AMMORTIZZABILI E PER PASSAGGI INTERNI (EX ART. 36 D.P.R. 633/72)
"TD27", # FATTURA PER AUTOCONSUMO O PER CESSIONI GRATUITE SENZA RIVALSA
)
# Copied from Documentazione valida a partire dal 1 ottobre 2020
# Rappresentazione tabellare del tracciato fattura ordinaria - excel
REGIME_FISCALE = (
"RF01", # Ordinario
"RF02", # Contribuenti minimi (art.1, c.96-117, L. 244/07)
"RF04", # Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR 633/72)
"RF05", # Vendita sali e tabacchi (art.74, c.1, DPR. 633/72)
"RF06", # Commercio fiammiferi (art.74, c.1, DPR 633/72)
"RF07", # Editoria (art.74, c.1, DPR 633/72)
"RF08", # Gestione servizi telefonia pubblica (art.74, c.1, DPR 633/72)
"RF09", # Rivendita documenti di trasporto pubblico e di sosta (art.74, c.1, DPR 633/72)
"RF10", # Intrattenimenti, giochi e altre attività di cui alla tariffa
# allegata al DPR 640/72 (art.74, c.6, DPR 633/72)
"RF11", # Agenzie viaggi e turismo (art.74-ter, DPR 633/72)
"RF12", # Agriturismo (art.5, c.2, L. 413/91)
"RF13", # Vendite a domicilio (art.25-bis, c.6, DPR 600/73)
"RF14", # Rivendita beni usati, oggetti d’arte, d’antiquariato o da collezione (art.36, DL 41/95)
"RF15", # Agenzie di vendite all’asta di oggetti d’arte, antiquariato o da collezione (art.40-bis, DL 41/95)
"RF16", # IVA per cassa P.A. (art.6, c.5, DPR 633/72)
"RF17", # IVA per cassa (art. 32-bis, DL 83/2012)
"RF18", # Altro
"RF19", # Regime forfettario (art.1, c.54-89, L. 190/2014)
)
# Copied from Documentazione valida a partire dal 1 ottobre 2020
# Rappresentazione tabellare del tracciato fattura ordinaria - excel
TIPO_CASSA = (
"TC01", # Cassa nazionale previdenza e assistenza avvocati e procuratori legali
"TC02", # Cassa previdenza dottori commercialisti
"TC03", # Cassa previdenza e assistenza geometri
"TC04", # Cassa nazionale previdenza e assistenza ingegneri e architetti liberi professionisti
"TC05", # Cassa nazionale del notariato
"TC06", # Cassa nazionale previdenza e assistenza ragionieri e periti commerciali
"TC07", # Ente nazionale assistenza agenti e rappresentanti di commercio (ENASARCO)
"TC08", # Ente nazionale previdenza e assistenza consulenti del lavoro (ENPACL)
"TC09", # Ente nazionale previdenza e assistenza medici (ENPAM)
"TC10", # Ente nazionale previdenza e assistenza farmacisti (ENPAF)
"TC11", # Ente nazionale previdenza e assistenza veterinari (ENPAV)
"TC12", # Ente nazionale previdenza e assistenza impiegati dell'agricoltura (ENPAIA)
"TC13", # Fondo previdenza impiegati imprese di spedizione e agenzie marittime
"TC14", # Istituto nazionale previdenza giornalisti italiani (INPGI)
"TC15", # Opera nazionale assistenza orfani sanitari italiani (ONAOSI)
"TC16", # Cassa autonoma assistenza integrativa giornalisti italiani (CASAGIT)
"TC17", # Ente previdenza periti industriali e periti industriali laureati (EPPI)
"TC18", # Ente previdenza e assistenza pluricategoriale (EPAP)
"TC19", # Ente nazionale previdenza e assistenza biologi (ENPAB)
"TC20", # Ente nazionale previdenza e assistenza professione infermieristica (ENPAPI)
"TC21", # Ente nazionale previdenza e assistenza psicologi (ENPAP)
"TC22", # INPS
)
# Copied from Documentazione valida a partire dal 1 ottobre 2020
# Rappresentazione tabellare del tracciato fattura ordinaria - excel
MODALITA_PAGAMENTO = (
"MP01", # contanti
"MP02", # assegno
"MP03", # assegno circolare
"MP04", # contanti presso Tesoreria
"MP05", # bonifico
"MP06", # vaglia cambiario
"MP07", # bollettino bancario
"MP08", # carta di pagamento
"MP09", # RID
"MP10", # RID utenze
"MP11", # RID veloce
"MP12", # RIBA
"MP13", # MAV
"MP14", # quietanza erario
"MP15", # giroconto su conti di contabilità speciale
"MP16", # domiciliazione bancaria
"MP17", # domiciliazione postale
"MP18", # bollettino di c/c postale
"MP19", # SEPA Direct Debit
"MP20", # SEPA Direct Debit CORE
"MP21", # SEPA Direct Debit B2B
"MP22", # Trattenuta su somme già riscosse
"MP23", # PagoPA
)
# Copied from Documentazione valida a partire dal 1 ottobre 2020
# Rappresentazione tabellare del tracciato fattura ordinaria - excel
TIPO_RITENUTA = (
"RT01", # ritenuta persone fisiche
"RT02", # ritenuta persone giuridiche
"RT03", # contributo INPS
"RT04", # contributo ENASARCO
"RT05", # contributo ENPAM
"RT06", # altro contributo previdenziale
)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/crypto.py 0000644 0001777 0001777 00000007545 14373374076 014645 0 ustar 00valhalla valhalla import base64
import binascii
import datetime
import io
import subprocess
try:
from defusedxml import ElementTree as ET
except ModuleNotFoundError:
import xml.etree.ElementTree as ET
from typing import BinaryIO, Union
from asn1crypto.cms import ContentInfo
from . import fattura as a38
class SignatureVerificationError(Exception):
pass
class InvalidSignatureError(SignatureVerificationError):
pass
class SignerCertificateError(SignatureVerificationError):
pass
class P7M:
"""
Parse a Fattura Elettronica encoded as a .p7m file
"""
def __init__(self, data: Union[str, bytes, BinaryIO]):
"""
If data is a string, it is taken as a file name.
If data is bytes, it is taken as p7m data.
Otherwise, data is taken as a file-like object that reads bytes data.
"""
if isinstance(data, str):
with open(data, "rb") as fd:
self.data = fd.read()
elif isinstance(data, bytes):
self.data = data
else:
self.data = data.read()
# Data might potentially be base64 encoded
try:
self.data = base64.b64decode(self.data, validate=True)
except binascii.Error:
pass
self.content_info = ContentInfo.load(self.data)
def is_expired(self) -> bool:
"""
Check if the signature has expired
"""
now = datetime.datetime.utcnow()
signed_data = self.get_signed_data()
for c in signed_data["certificates"]:
if c.name != "certificate":
# The signatures I've seen so far use 'certificate' only
continue
expiration_date = c.chosen["tbs_certificate"]["validity"]["not_after"].chosen.native.replace(tzinfo=None)
if expiration_date <= now:
return True
return False
def get_signed_data(self):
"""
Return the SignedData part of the P7M file
"""
if self.content_info["content_type"].native != "signed_data":
raise RuntimeError("p7m data is not an instance of signed_data")
signed_data = self.content_info["content"]
if signed_data["version"].native != "v1":
raise RuntimeError(f"ContentInfo/SignedData.version is {signed_data['version'].native} instead of v1")
return signed_data
def get_payload(self):
"""
Return the raw signed data
"""
signed_data = self.get_signed_data()
encap_content_info = signed_data["encap_content_info"]
return encap_content_info["content"].native
def get_fattura(self):
"""
Return the parsed XML data
"""
data = io.BytesIO(self.get_payload())
tree = ET.parse(data)
return a38.auto_from_etree(tree.getroot())
def verify_signature(self, certdir):
"""
Verify the signature on the file
"""
res = subprocess.run([
"openssl", "cms", "-verify", "-inform", "DER", "-CApath", certdir, "-noout"],
input=self.data,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
# From openssl cms manpage:
# 0 The operation was completely successfully.
# 1 An error occurred parsing the command options.
# 2 One of the input files could not be read.
# 3 An error occurred creating the CMS file or when reading the MIME message.
# 4 An error occurred decrypting or verifying the message.
# 5 The message was verified correctly but an error occurred writing out the signers certificates.
if res.returncode == 0:
pass
elif res.returncode == 4:
raise InvalidSignatureError(res.stderr)
elif res.returncode == 5:
raise SignerCertificateError(res.stderr)
else:
raise RuntimeError(res.stderr)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/diff.py 0000644 0001777 0001777 00000004375 14373374076 014233 0 ustar 00valhalla valhalla from typing import Any, List, Optional
from . import fields
from .traversal import Annotation, Traversal
class Difference(Annotation):
def __init__(self, prefix: Optional[str], field: "fields.Field", first: Any, second: Any):
super().__init__(prefix, field)
self.first = first
self.second = second
def __str__(self):
return "{}: first: {}, second: {}".format(
self.qualified_field,
self.field.to_str(self.first),
self.field.to_str(self.second))
class MissingOne(Difference):
def __str__(self):
if self.first is None:
return "{}: first is not set".format(self.qualified_field)
else:
return "{}: second is not set".format(self.qualified_field)
class ExtraItems(Difference):
def __str__(self):
if len(self.first) > len(self.second):
diff = len(self.first) - len(self.second)
longer = "first"
else:
diff = len(self.second) - len(self.first)
longer = "second"
if diff == 1:
return "{}: {} has 1 extra element".format(self.qualified_field, longer)
else:
return "{}: {} has {} extra elements".format(self.qualified_field, longer, diff)
class Diff(Traversal):
def __init__(self, prefix: Optional[str] = None, differences: Optional[List[Difference]] = None):
super().__init__(prefix)
self.differences: List[Difference]
if differences is None:
self.differences = []
else:
self.differences = differences
def with_prefix(self, prefix: str):
return Diff(prefix, self.differences)
def add_different(self, field: "fields.Field", first: Any, second: Any):
self.differences.append(Difference(self.prefix, field, first, second))
def add_only_first(self, field: "fields.Field", first: Any):
self.differences.append(MissingOne(self.prefix, field, first, None))
def add_only_second(self, field: "fields.Field", second: Any):
self.differences.append(MissingOne(self.prefix, field, None, second))
def add_different_length(self, field: "fields.Field", first: Any, second: Any):
self.differences.append(ExtraItems(self.prefix, field, first, second))
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704882565.0
a38-0.1.8/a38/fattura.py 0000644 0001777 0001777 00000104322 14547470605 014760 0 ustar 00valhalla valhalla from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, Dict, Union
from . import consts, fields, models
if TYPE_CHECKING:
import xml.etree.ElementTree as ET
from .fattura_semplificata import FatturaElettronicaSemplificata
#
# This file describes the data model of the Italian Fattura Elettronica.
#
# Models and fields are inspired from Django's ORM.
#
# XML tag names are built automatically from the field names, and can be
# specified explicitly with the xmltag argument.
#
# Models can be used as fields using `fields.ModelField`. Specifying a Model
# class as a field, automatically wraps it in a `ModelField`.
#
__all__ = []
def export(el):
"""
Add a symbol to __all__ for export
"""
global __all__
__all__.append(el.__name__)
return el
NS = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2"
NS10 = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.0"
NS_SIG = "http://www.w3.org/2000/09/xmldsig#"
class FullNameMixin:
"""
Helper for classes that have the nome+cognome/denominazione way of naming.
Validate that nome+cognome and denominazione are mutually exclusive, and
provide a full_name property that returns whichever is set.
"""
@property
def full_name(self):
"""
Return denominazione or "{nome} {cognome}", whichever is set.
If none are set, return None
"""
if self.denominazione is not None:
return self.denominazione
elif self.nome is not None and self.cognome is not None:
return self.nome + " " + self.cognome
else:
return None
def validate_model(self, validation):
super().validate_model(validation)
if self.denominazione is None:
if self.nome is None and self.cognome is None:
validation.add_error(
(
self._meta["nome"],
self._meta["cognome"],
self._meta["denominazione"],
),
"nome and cognome, or denominazione, must be set",
)
elif self.nome is None:
validation.add_error(
self._meta["nome"],
"nome and cognome must both be set if denominazione is empty",
)
elif self.cognome is None:
validation.add_error(
self._meta["cognome"],
"nome and cognome must both be set if denominazione is empty",
)
else:
should_not_be_set = []
if self.nome is not None:
should_not_be_set.append(self._meta["nome"])
if self.cognome is not None:
should_not_be_set.append(self._meta["cognome"])
if should_not_be_set:
validation.add_error(
should_not_be_set,
"{} must not be set if denominazione is not empty".format(
" and ".join(x.name for x in should_not_be_set)
),
)
@export
class IdFiscale(models.Model):
id_paese = fields.StringField(length=2)
id_codice = fields.StringField(max_length=28)
@export
class IdTrasmittente(IdFiscale):
pass
@export
class IdFiscaleIVA(IdFiscale):
pass
@export
class ContattiTrasmittente(models.Model):
telefono = fields.StringField(min_length=5, max_length=12, null=True)
email = fields.StringField(min_length=7, max_length=256, null=True)
@export
class DatiTrasmissione(models.Model):
id_trasmittente = IdTrasmittente
progressivo_invio = fields.ProgressivoInvioField()
formato_trasmissione = fields.StringField(length=5, choices=("FPR12", "FPA12"))
codice_destinatario = fields.StringField(
null=True, min_length=6, max_length=7, default="0000000"
)
contatti_trasmittente = fields.ModelField(ContattiTrasmittente, null=True)
pec_destinatario = fields.StringField(
null=True, min_length=8, max_length=256, xmltag="PECDestinatario"
)
def validate_model(self, validation):
super().validate_model(validation)
if self.codice_destinatario is None and self.pec_destinatario is None:
validation.add_error(
(self._meta["codice_destinatario"], self._meta["pec_destinatario"]),
"one of codice_destinatario or pec_destinatario must be set",
)
# Se la fattura deve essere recapitata ad un soggetto che intende
# ricevere le fatture elettroniche attraverso il canale PEC, il campo
# deve essere valorizzato con sette zeri (“0000000”) e deve essere
# valorizzato il campo PECDestinatario
if self.pec_destinatario is None and self.codice_destinatario == "0000000":
validation.add_error(
(self._meta["codice_destinatario"], self._meta["pec_destinatario"]),
"pec_destinatario has no value while codice_destinatario has value 0000000",
code="00426",
)
if (
self.pec_destinatario is not None
and self.codice_destinatario is not None
and self.codice_destinatario != "0000000"
):
validation.add_error(
(self._meta["codice_destinatario"], self._meta["pec_destinatario"]),
"pec_destinatario has value while codice_destinatario has value 0000000",
code="00426",
)
if self.formato_trasmissione == "FPA12" and len(self.codice_destinatario) == 7:
validation.add_error(
self._meta["codice_destinatario"],
"codice_destinatario has 7 characters on a Fattura PA",
code="00427",
)
if self.formato_trasmissione == "FPR12" and len(self.codice_destinatario) == 6:
validation.add_error(
self._meta["codice_destinatario"],
"codice_destinatario has 6 characters on a Fattura Privati",
code="00427",
)
@export
class Anagrafica(FullNameMixin, models.Model):
denominazione = fields.StringField(max_length=80, null=True)
nome = fields.StringField(max_length=60, null=True)
cognome = fields.StringField(max_length=60, null=True)
titolo = fields.StringField(min_length=2, max_length=10, null=True)
cod_eori = fields.StringField(
xmltag="CodEORI", min_length=13, max_length=17, null=True
)
@export
class DatiAnagraficiCedentePrestatore(models.Model):
__xmltag__ = "DatiAnagrafici"
id_fiscale_iva = IdFiscaleIVA
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
anagrafica = Anagrafica
albo_professionale = fields.StringField(max_length=60, null=True)
provincia_albo = fields.StringField(length=2, null=True)
numero_iscrizione_albo = fields.StringField(max_length=60, null=True)
data_iscrizione_albo = fields.DateField(null=True)
regime_fiscale = fields.StringField(length=4, choices=consts.REGIME_FISCALE)
@export
class IndirizzoType(models.Model):
indirizzo = fields.StringField(max_length=60)
numero_civico = fields.StringField(max_length=8, null=True)
cap = fields.StringField(xmltag="CAP", length=5)
comune = fields.StringField(max_length=60)
provincia = fields.StringField(length=2, null=True)
nazione = fields.StringField(length=2)
@export
class Sede(IndirizzoType):
pass
@export
class IscrizioneREA(models.Model):
ufficio = fields.StringField(length=2)
numero_rea = fields.StringField(xmltag="NumeroREA", max_length=20)
capitale_sociale = fields.StringField(min_length=4, max_length=15, null=True)
socio_unico = fields.StringField(length=2, choices=("SU", "SM"), null=True)
stato_liquidazione = fields.StringField(length=2, choices=("LS", "LN"))
@export
class Contatti(models.Model):
telefono = fields.StringField(min_length=5, max_length=12, null=True)
fax = fields.StringField(min_length=5, max_length=12, null=True)
email = fields.StringField(min_length=7, max_length=256, null=True)
@export
class StabileOrganizzazione(IndirizzoType):
pass
@export
class CedentePrestatore(models.Model):
dati_anagrafici = DatiAnagraficiCedentePrestatore
sede = Sede
stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True)
iscrizione_rea = fields.ModelField(IscrizioneREA, null=True)
contatti = fields.ModelField(Contatti, null=True)
riferimento_amministrazione = fields.StringField(max_length=20, null=True)
@export
class DatiAnagraficiCessionarioCommittente(models.Model):
__xmltag__ = "DatiAnagrafici"
id_fiscale_iva = fields.ModelField(IdFiscaleIVA, null=True)
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
anagrafica = Anagrafica
def validate_model(self, validation):
super().validate_model(validation)
if self.id_fiscale_iva is None and self.codice_fiscale is None:
validation.add_error(
(self._meta["id_fiscale_iva"], self._meta["codice_fiscale"]),
"at least one of id_fiscale_iva and codice_fiscale needs to have a value",
code="00417",
)
@export
class RappresentanteFiscale(FullNameMixin, models.Model):
id_fiscale_iva = fields.ModelField(IdFiscaleIVA, null=True)
denominazione = fields.StringField(max_length=80, null=True)
nome = fields.StringField(max_length=60, null=True)
cognome = fields.StringField(max_length=60, null=True)
@export
class CessionarioCommittente(models.Model):
dati_anagrafici = DatiAnagraficiCessionarioCommittente
sede = Sede
stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True)
rappresentante_fiscale = fields.ModelField(RappresentanteFiscale, null=True)
@export
class DatiAnagraficiRappresentante(models.Model):
__xmltag__ = "DatiAnagrafici"
id_fiscale_iva = IdFiscaleIVA
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
anagrafica = Anagrafica
@export
class RappresentanteFiscaleCedentePrestatore(models.Model):
__xmltag__ = "RappresentanteFiscale"
dati_anagrafici = DatiAnagraficiRappresentante
@export
class DatiAnagraficiTerzoIntermediario(models.Model):
__xmltag__ = "DatiAnagrafici"
id_fiscale_iva = IdFiscaleIVA
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
anagrafica = Anagrafica
@export
class TerzoIntermediarioOSoggettoEmittente(models.Model):
dati_anagrafici = DatiAnagraficiTerzoIntermediario
@export
class FatturaElettronicaHeader(models.Model):
dati_trasmissione = DatiTrasmissione
cedente_prestatore = CedentePrestatore
rappresentante_fiscale = models.ModelField(
RappresentanteFiscaleCedentePrestatore, null=True
)
cessionario_committente = CessionarioCommittente
terzo_intermediario_o_soggetto_emittente = models.ModelField(
TerzoIntermediarioOSoggettoEmittente, null=True
)
soggetto_emittente = fields.StringField(length=2, choices=("CC", "TZ"), null=True)
@export
class DatiRitenuta(models.Model):
tipo_ritenuta = fields.StringField(length=4, choices=consts.TIPO_RITENUTA)
importo_ritenuta = fields.DecimalField(max_length=15)
aliquota_ritenuta = fields.DecimalField(max_length=6)
causale_pagamento = fields.StringField(max_length=2)
@export
class DatiBollo(models.Model):
bollo_virtuale = fields.StringField(length=2, choices=("SI",))
importo_bollo = fields.DecimalField(max_length=15)
@export
class DatiCassaPrevidenziale(models.Model):
tipo_cassa = fields.StringField(length=4, choices=consts.TIPO_CASSA)
al_cassa = fields.DecimalField(max_length=6)
importo_contributo_cassa = fields.DecimalField(max_length=15)
imponibile_cassa = fields.DecimalField(max_length=15)
aliquota_iva = fields.DecimalField(max_length=6, xmltag="AliquotaIVA")
ritenuta = fields.StringField(length=2, choices=("SI",), null=True)
natura = fields.StringField(
min_length=2, max_length=4, choices=consts.NATURA_IVA, null=True
)
riferimento_amministrazione = fields.StringField(max_length=20, null=True)
def validate_model(self, validation):
super().validate_model(validation)
if self.aliquota_iva == 0 and self.natura is None:
validation.add_error(
self._meta["natura"],
"field is empty while aliquota_iva is zero",
code="00413",
)
if self.aliquota_iva != 0 and self.natura is not None:
validation.add_error(
self._meta["natura"],
"field has value while aliquota_iva is not zero",
code="00414",
)
@export
class ScontoMaggiorazione(models.Model):
tipo = fields.StringField(length=2, choices=("SC", "MG"))
percentuale = fields.DecimalField(max_length=6, null=True)
importo = fields.DecimalField(max_length=15, null=True)
@export
class DatiGeneraliDocumento(models.Model):
tipo_documento = fields.StringField(length=4, choices=consts.TIPO_DOCUMENTO)
divisa = fields.StringField()
data = fields.DateField()
numero = fields.StringField(max_length=20)
dati_ritenuta = fields.ModelListField(DatiRitenuta, null=True)
dati_bollo = fields.ModelField(DatiBollo, null=True)
dati_cassa_previdenziale = fields.ModelListField(DatiCassaPrevidenziale, null=True)
sconto_maggiorazione = fields.ModelListField(ScontoMaggiorazione, null=True)
importo_totale_documento = fields.DecimalField(max_length=15, null=True)
arrotondamento = fields.DecimalField(max_length=15, null=True)
causale = fields.ListField(fields.StringField(max_length=200), null=True)
art73 = fields.StringField(length=2, choices=("SI",), null=True, xmltag="Art73")
def validate_model(self, validation):
super().validate_model(validation)
has_dati_cassa_previdenziale_ritenuta = False
for dcp in self.dati_cassa_previdenziale:
if dcp.ritenuta == "SI":
has_dati_cassa_previdenziale_ritenuta = True
break
if has_dati_cassa_previdenziale_ritenuta and not self.dati_ritenuta.has_value():
validation.add_error(
self._meta["ritenuta"],
"field empty when dati_cassa_previdenziale.ritenuta is SI",
code="00415",
)
if self.numero is None or not re.search(r"\d", self.numero):
validation.add_error(
self._meta["numero"],
"numero must contain at least one number",
code="00425",
)
@export
class AltriDatiGestionali(models.Model):
tipo_dato = fields.StringField(max_length=10)
riferimento_testo = fields.StringField(max_length=60, null=True)
riferimento_numero = fields.DecimalField(max_length=21, decimals=(2, 8), null=True)
riferimento_data = fields.DateField(null=True)
@export
class CodiceArticolo(models.Model):
codice_tipo = fields.StringField(max_length=35)
codice_valore = fields.StringField(max_length=35)
@export
class DettaglioLinee(models.Model):
numero_linea = fields.IntegerField(max_length=4)
tipo_cessione_prestazione = fields.StringField(
length=2, choices=("SC", "PR", "AB", "AC"), null=True
)
codice_articolo = fields.ModelListField(CodiceArticolo, null=True)
descrizione = fields.StringField(max_length=1000)
quantita = fields.DecimalField(max_length=21, decimals=(2, 8), null=True)
unita_misura = fields.StringField(max_length=10, null=True)
data_inizio_periodo = fields.DateField(null=True)
data_fine_periodo = fields.DateField(null=True)
prezzo_unitario = fields.DecimalField(max_length=21, decimals=(2, 8))
sconto_maggiorazione = fields.ModelListField(ScontoMaggiorazione, null=True)
prezzo_totale = fields.DecimalField(max_length=21, decimals=(2, 8))
aliquota_iva = fields.DecimalField(xmltag="AliquotaIVA", max_length=6)
ritenuta = fields.StringField(length=2, choices=("SI",), null=True)
natura = fields.StringField(
min_length=2, max_length=4, null=True, choices=consts.NATURA_IVA
)
riferimento_amministrazione = fields.StringField(max_length=20, null=True)
altri_dati_gestionali = fields.ModelListField(AltriDatiGestionali, null=True)
def validate_model(self, validation):
super().validate_model(validation)
if self.quantita is None and self.unita_misura is not None:
validation.add_error(
self._meta["quantita"], "field must be present when unita_misura is set"
)
if self.quantita is not None and self.unita_misura is None:
validation.add_error(
self._meta["unita_misura"], "field must be present when quantita is set"
)
if self.aliquota_iva == 0 and self.natura is None:
validation.add_error(
self._meta["natura"],
"natura non presente a fronte di aliquota_iva pari a zero",
code="00400",
)
if self.aliquota_iva != 0 and self.natura is not None:
validation.add_error(
self._meta["natura"],
"natura presente a fronte di aliquota_iva diversa da zero",
code="00401",
)
def autofill_prezzo_totale(self):
"""
Compute prezzo_totale if it was not set
"""
if self.prezzo_totale is not None:
return
if self.quantita is None:
self.prezzo_totale = self.prezzo_unitario
else:
self.prezzo_totale = self.prezzo_unitario * self.quantita
@export
class DatiRiepilogo(models.Model):
aliquota_iva = fields.DecimalField(xmltag="AliquotaIVA", max_length=6)
natura = fields.StringField(
min_length=2, max_length=4, null=True, choices=consts.NATURA_IVA
)
spese_accessorie = fields.DecimalField(max_length=15, null=True)
arrotondamento = fields.DecimalField(max_length=21, decimals=(2, 8), null=True)
# FIXME: Su questo valore il sistema effettua un controllo per verificare
# la correttezza del calcolo; per i dettagli sull’algoritmo di calcolo si
# rimanda al file Elenco controlli versione 1.4 presente sul sito
# www.fatturapa.gov.it.
imponibile_importo = fields.DecimalField(max_length=15)
imposta = fields.DecimalField(max_length=15)
esigibilita_iva = fields.StringField(
xmltag="EsigibilitaIVA", length=1, choices=("I", "D", "S"), null=True
)
riferimento_normativo = fields.StringField(max_length=100, null=True)
def validate_model(self, validation):
super().validate_model(validation)
if self.aliquota_iva == 0 and self.natura is None:
validation.add_error(
self._meta["natura"],
"field is empty while aliquota_iva is zero",
code="00429",
)
if self.aliquota_iva != 0 and self.natura is not None:
validation.add_error(
self._meta["natura"],
"field has value while aliquota_iva is not zero",
code="00430",
)
@export
class DatiBeniServizi(models.Model):
dettaglio_linee = fields.ModelListField(DettaglioLinee)
dati_riepilogo = fields.ModelListField(DatiRiepilogo)
def add_dettaglio_linee(self, **kw):
"""
Convenience method to add entries to dettaglio_linee, autocomputing
numero_linea and prezzo_totale when missing.
prezzo_totale is just computed as prezzo_unitario * quantita.
For anything more complicated, you need to compute prezzo_totale
yourself add pass it explicitly, or better, extend this function and
submit a pull request.
"""
kw.setdefault("numero_linea", len(self.dettaglio_linee) + 1)
d = DettaglioLinee(**kw)
d.autofill_prezzo_totale()
self.dettaglio_linee.append(d)
def build_dati_riepilogo(self):
"""
Convenience method to compute dati_riepilogo. It replaces existing
values in dati_riepilogo.
It only groups dettaglio_linee by aliquota, sums prezzo_totale to
compute imponibile, and applies IVA.
For anything more complicated, you need to compute dati_riepilogo
yourself, or better, extend this function and submit a pull request.
"""
from collections import defaultdict
# Group by aliquota
by_aliquota = defaultdict(list)
for linea in self.dettaglio_linee:
by_aliquota[(linea.aliquota_iva, linea.natura)].append(linea)
self.dati_riepilogo = []
for (aliquota, natura), linee in sorted(by_aliquota.items()):
imponibile = sum(linea.prezzo_totale for linea in linee)
imposta = imponibile * aliquota / 100
self.dati_riepilogo.append(
DatiRiepilogo(
aliquota_iva=aliquota,
imponibile_importo=imponibile,
imposta=imposta,
esigibilita_iva="I",
natura=natura,
)
)
@export
class DatiDocumentiCorrelati(models.Model):
riferimento_numero_linea = fields.ListField(
fields.IntegerField(max_length=4), null=True
)
id_documento = fields.StringField(max_length=20)
data = fields.DateField(null=True)
num_item = fields.StringField(max_length=20, null=True)
codice_commessa_convenzione = fields.StringField(max_length=100, null=True)
codice_cup = fields.StringField(max_length=15, xmltag="CodiceCUP", null=True)
codice_cig = fields.StringField(max_length=15, xmltag="CodiceCIG", null=True)
@export
class DatiOrdineAcquisto(DatiDocumentiCorrelati):
pass
@export
class DatiContratto(DatiDocumentiCorrelati):
pass
@export
class DatiConvenzione(DatiDocumentiCorrelati):
pass
@export
class DatiRicezione(DatiDocumentiCorrelati):
pass
@export
class DatiFattureCollegate(DatiDocumentiCorrelati):
pass
@export
class DatiAnagraficiVettore(models.Model):
id_fiscale_iva = IdFiscaleIVA
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
anagrafica = Anagrafica
numero_licenza_guida = fields.StringField(max_length=20, null=True)
@export
class IndirizzoResa(IndirizzoType):
pass
@export
class DatiTrasporto(models.Model):
dati_anagrafici_vettore = fields.ModelField(DatiAnagraficiVettore, null=True)
mezzo_trasporto = fields.StringField(max_length=80, null=True)
causale_trasporto = fields.StringField(max_length=100, null=True)
numero_colli = fields.IntegerField(max_length=4, null=True)
descrizione = fields.StringField(max_length=100, null=True)
unita_misura_peso = fields.StringField(max_length=10, null=True)
peso_lordo = fields.DecimalField(max_length=7, null=True)
peso_netto = fields.DecimalField(max_length=7, null=True)
data_ora_ritiro = fields.DateTimeField(null=True)
data_inizio_trasporto = fields.DateField(null=True)
tipo_resa = fields.StringField(length=3, null=True)
indirizzo_resa = fields.ModelField(IndirizzoResa, null=True)
data_ora_consegna = fields.DateTimeField(null=True)
@export
class DatiDDT(models.Model):
__xmltag__ = "DatiDDT"
numero_ddt = fields.StringField(max_length=20, xmltag="NumeroDDT")
data_ddt = fields.DateField(xmltag="DataDDT")
riferimento_numero_linea = fields.ListField(
fields.IntegerField(max_length=4), null=True
)
@export
class FatturaPrincipale(models.Model):
numero_fattura_principale = fields.StringField(max_length=20)
data_fattura_principale = fields.DateField()
@export
class DatiGenerali(models.Model):
dati_generali_documento = DatiGeneraliDocumento
dati_ordine_acquisto = fields.ModelListField(DatiOrdineAcquisto, null=True)
dati_contratto = fields.ModelListField(DatiContratto, null=True)
dati_convenzione = fields.ModelListField(DatiConvenzione, null=True)
dati_ricezione = fields.ModelListField(DatiRicezione, null=True)
dati_fatture_collegate = fields.ModelListField(DatiFattureCollegate, null=True)
# dati_sal =
dati_ddt = fields.ModelListField(DatiDDT, null=True)
dati_trasporto = fields.ModelField(DatiTrasporto, null=True)
fattura_principale = fields.ModelField(FatturaPrincipale, null=True)
def validate_model(self, validation):
super().validate_model(validation)
for idx, dfc in enumerate(self.dati_fatture_collegate):
if dfc.data is None:
continue
if self.dati_generali_documento.data < dfc.data:
validation.add_error(
(
dfc._meta["data"],
self.dati_generali_documento._meta["data"],
),
f"dati_generali_documento[{idx}].data"
" is earlier than dati_fatture_collegate.data",
code="00418",
)
@export
class DettaglioPagamento(models.Model):
beneficiario = fields.StringField(max_length=200, null=True)
modalita_pagamento = fields.StringField(length=4, choices=consts.MODALITA_PAGAMENTO)
data_riferimento_termini_pagamento = fields.DateField(null=True)
giorni_termini_pagamento = fields.IntegerField(max_length=3, null=True)
data_scadenza_pagamento = fields.DateField(null=True)
importo_pagamento = fields.DecimalField(max_length=15)
cod_ufficio_postale = fields.StringField(max_length=20, null=True)
cognome_quietanzante = fields.StringField(max_length=60, null=True)
nome_quietanzante = fields.StringField(max_length=60, null=True)
cf_quietanzante = fields.StringField(
max_length=16, null=True, xmltag="CFQuietanzante"
)
titolo_quietanzante = fields.StringField(min_length=2, max_length=10, null=True)
istituto_finanziario = fields.StringField(max_length=80, null=True)
iban = fields.StringField(min_length=15, max_length=34, null=True, xmltag="IBAN")
abi = fields.StringField(length=5, null=True, xmltag="ABI")
cab = fields.StringField(length=5, null=True, xmltag="CAB")
bic = fields.StringField(min_length=8, max_length=11, null=True, xmltag="BIC")
sconto_pagamento_anticipato = fields.DecimalField(max_length=15, null=True)
data_limite_pagamento_anticipato = fields.DateField(null=True)
penalita_pagamenti_ritardati = fields.DecimalField(max_length=15, null=True)
data_decorrenza_penale = fields.DateField(null=True)
codice_pagamento = fields.StringField(max_length=60, null=True)
@export
class DatiPagamento(models.Model):
condizioni_pagamento = fields.StringField(
length=4, choices=("TP01", "TP02", "TP03")
)
dettaglio_pagamento = fields.ModelListField(DettaglioPagamento)
@export
class Allegati(models.Model):
nome_attachment = fields.StringField(max_length=60)
algoritmo_compressione = fields.StringField(max_length=10, null=True)
formato_attachment = fields.StringField(max_length=10, null=True)
descrizione_attachment = fields.StringField(max_length=100, null=True)
attachment = fields.Base64BinaryField()
@export
class DatiVeicoli(models.Model):
data = fields.DateField()
totale_percorso = fields.StringField(max_length=15)
@export
class FatturaElettronicaBody(models.Model):
dati_generali = DatiGenerali
dati_beni_servizi = DatiBeniServizi
dati_veicoli = models.ModelField(DatiVeicoli, null=True)
dati_pagamento = fields.ModelListField(DatiPagamento, null=True)
allegati = fields.ModelListField(Allegati, null=True)
def build_importo_totale_documento(self):
"""
Convenience method to compute
dati_generali.dati_generali_documento.importo_totale_documento.
It replaces an existing value in importo_totale_documento.
It only adds imponibile_importo and imposta values from
dati_beni_servizi.dati_riepilogo.
For anything more complicated, you need to compute
importo_totale_documento yourself, or better, extend this function and
submit a pull request.
"""
totale = sum(
r.imponibile_importo + r.imposta
for r in self.dati_beni_servizi.dati_riepilogo
)
self.dati_generali.dati_generali_documento.importo_totale_documento = totale
def validate_model(self, validation):
super().validate_model(validation)
has_ritenute = False
has_aliquote_iva = False
for dl in self.dati_beni_servizi.dettaglio_linee:
if dl.ritenuta == "SI":
has_ritenute = True
if dl.aliquota_iva is not None:
has_aliquote_iva = True
if (
has_ritenute
and not self.dati_generali.dati_generali_documento.dati_ritenuta.has_value()
):
validation.add_error(
self.dati_generali.dati_generali_documento._meta["dati_ritenuta"],
"field empty while at least one of dati_beni_servizi.dettaglio_linee.ritenuta is SI",
code="00411",
)
for dcp in self.dati_generali.dati_generali_documento.dati_cassa_previdenziale:
if dcp.aliquota_iva is not None:
has_aliquote_iva = True
if not self.dati_beni_servizi.dati_riepilogo and has_aliquote_iva:
validation.add_error(
self.dati_beni_servizi._meta["dati_riepilogo"],
"dati_riepilogo is empty while there is at least an aliquota_iva"
" in dettaglio_linee or dati_cassa_previdenziale",
code="00419",
)
@export
class Fattura(models.Model):
__xmlns__ = NS
__xmltag__ = "FatturaElettronica"
fattura_elettronica_header = FatturaElettronicaHeader
fattura_elettronica_body = fields.ModelListField(FatturaElettronicaBody, min_num=1)
signature = fields.NotImplementedField(null=True, xmlns=NS_SIG)
def __init__(self, *args, **kw):
super().__init__(*args, **kw)
self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = (
self.get_versione()
)
def get_versione(self):
return None
def get_xmlattrs(self):
return {"versione": self.get_versione()}
def validate_model(self, validation):
super().validate_model(validation)
if (
self.get_versione()
!= self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione
):
validation.add_error(
self.fattura_elettronica_header.dati_trasmissione._meta[
"formato_trasmissione"
],
"formato_trasmissione should be {}".format(self.get_versione()),
code="00428",
)
def to_xml(self, builder):
with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b:
with b.override_default_namespace(None) as b1:
for name, field in self._meta.items():
field.to_xml(b1, getattr(self, name))
def build_etree(self, lxml=False):
"""
Build and return an ElementTree with the fattura in XML format
"""
self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = (
self.get_versione()
)
if lxml:
from a38.builder import LXMLBuilder
builder = LXMLBuilder()
else:
from a38.builder import Builder
builder = Builder()
builder.default_namespace = NS
self.to_xml(builder)
return builder.get_tree()
def from_etree(self, el):
versione = el.attrib.get("versione", None)
if versione is None:
raise RuntimeError(
"root element {} misses attribute 'versione'".format(el.tag)
)
if versione != self.get_versione():
raise RuntimeError(
"root element versione is {} instead of {}".format(
versione, self.get_versione()
)
)
return super().from_etree(el)
@export
class FatturaPrivati12(Fattura):
"""
Fattura privati 1.2
"""
def get_versione(self):
return "FPR12"
@export
class FatturaPA12(Fattura):
"""
Fattura PA 1.2
"""
def get_versione(self):
return "FPA12"
@export
def auto_from_etree(
root: ET.ElementTree,
) -> Union[Fattura, FatturaElettronicaSemplificata]:
"""
Instantiate a Fattura or FatturaElettronicaSemplificata from a parsed XML
"""
from .fattura_semplificata import NS10, FatturaElettronicaSemplificata
tagname_ordinaria = "{{{}}}FatturaElettronica".format(NS)
tagname_semplificata = "{{{}}}FatturaElettronicaSemplificata".format(NS10)
versione = root.attrib.get("versione", None)
if root.tag == tagname_ordinaria:
if versione is None:
raise RuntimeError(
"root element {} misses attribute 'versione'".format(root.tag)
)
if versione == "FPR12":
res = FatturaPrivati12()
elif versione == "FPA12":
res = FatturaPA12()
else:
raise RuntimeError("unsupported versione {}".format(versione))
elif root.tag == tagname_semplificata:
if versione is None:
raise RuntimeError(
"root element {} misses attribute 'versione'".format(root.tag)
)
if versione == "FSM10":
res = FatturaElettronicaSemplificata()
else:
raise RuntimeError("unsupported versione {}".format(versione))
else:
raise RuntimeError(
"Root element {} is neither {} nor {}".format(
root.tag, tagname_ordinaria, tagname_semplificata
)
)
res.from_etree(root)
return res
@export
def auto_from_dict(
data: Dict[str, Any]
) -> Union[Fattura, FatturaElettronicaSemplificata]:
"""
Given the equivalent of a Fattura.to_jsonable() data structure,
reconstruct the fattura
"""
from .fattura_semplificata import FatturaElettronicaSemplificata
try:
formato = data["fattura_elettronica_header"]["dati_trasmissione"][
"formato_trasmissione"
]
except KeyError:
raise RuntimeError(
"fattura_elettronica_header.dati_trasmissione.formato_trasmissione not found in input"
)
if formato == "FPR12":
return FatturaPrivati12(**data)
elif formato == "FPA12":
return FatturaPA12(**data)
elif formato == "FSM10":
return FatturaElettronicaSemplificata(**data)
else:
raise RuntimeError(f"Unsupported formato_trasmissione: {formato!r}")
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/fattura_semplificata.py 0000644 0001777 0001777 00000014363 14373374076 017510 0 ustar 00valhalla valhalla from __future__ import annotations
from . import consts, fields, models
from .fattura import (Allegati, FullNameMixin, IdFiscaleIVA, IdTrasmittente,
IscrizioneREA, Sede, StabileOrganizzazione)
NS10 = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.0"
class DatiTrasmissione(models.Model):
id_trasmittente = IdTrasmittente
progressivo_invio = fields.ProgressivoInvioField()
formato_trasmissione = fields.StringField(length=5, choices=("FSM10",))
codice_destinatario = fields.StringField(null=True, min_length=6, max_length=7, default="0000000")
pec_destinatario = fields.StringField(null=True, min_length=8, max_length=256, xmltag="PECDestinatario")
class RappresentanteFiscale(FullNameMixin, models.Model):
id_fiscale_iva = IdFiscaleIVA
denominazione = fields.StringField(max_length=80, null=True)
nome = fields.StringField(max_length=60, null=True)
cognome = fields.StringField(max_length=60, null=True)
class CedentePrestatore(FullNameMixin, models.Model):
id_fiscale_iva = IdFiscaleIVA
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
denominazione = fields.StringField(max_length=80, null=True)
nome = fields.StringField(max_length=60, null=True)
cognome = fields.StringField(max_length=60, null=True)
sede = Sede
stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True)
rappresentante_fiscale = models.ModelField(RappresentanteFiscale, null=True)
iscrizione_rea = fields.ModelField(IscrizioneREA, null=True)
regime_fiscale = fields.StringField(
length=4, choices=("RF01", "RF02", "RF04", "RF05", "RF06", "RF07",
"RF08", "RF09", "RF10", "RF11", "RF12", "RF13",
"RF14", "RF15", "RF16", "RF17", "RF18", "RF19"))
class IdentificativiFiscali(models.Model):
id_fiscale_iva = IdFiscaleIVA
codice_fiscale = fields.StringField(min_length=11, max_length=16, null=True)
class AltriDatiIdentificativi(FullNameMixin, models.Model):
denominazione = fields.StringField(max_length=80, null=True)
nome = fields.StringField(max_length=60, null=True)
cognome = fields.StringField(max_length=60, null=True)
sede = Sede
stabile_organizzazione = fields.ModelField(StabileOrganizzazione, null=True)
rappresentante_fiscale = models.ModelField(RappresentanteFiscale, null=True)
class CessionarioCommittente(models.Model):
identificativi_fiscali = IdentificativiFiscali
altri_dati_identificativi = AltriDatiIdentificativi
class FatturaElettronicaHeader(models.Model):
dati_trasmissione = DatiTrasmissione
cedente_prestatore = CedentePrestatore
cessionario_committente = CessionarioCommittente
soggetto_emittente = fields.StringField(length=2, choices=("CC", "TZ"), null=True)
class DatiGeneraliDocumento(models.Model):
tipo_documento = fields.StringField(length=4, choices=("TD07", "TD08", "TD09"))
divisa = fields.StringField()
data = fields.DateField()
numero = fields.StringField(max_length=20)
class DatiFatturaRettificata(models.Model):
numero_fr = fields.StringField(max_length=20, xmltag="NumeroFR")
data_fr = fields.DateField(xmltag="DataFR")
elementi_rettificati = fields.StringField(max_length=1000)
class DatiGenerali(models.Model):
dati_generali_documento = DatiGeneraliDocumento
dati_fattura_rettificata = fields.ModelField(DatiFatturaRettificata, null=True)
class DatiIVA(models.Model):
imposta = fields.DecimalField(max_length=15)
aliquota = fields.DecimalField(max_length=6)
class DatiBeniServizi(models.Model):
descrizione = fields.StringField(max_length=1000)
importo = fields.DecimalField(max_length=15)
dati_iva = DatiIVA
natura = fields.StringField(length=2, null=True, choices=consts.NATURA_IVA)
riferimento_normativo = fields.StringField(max_length=100, null=True)
class FatturaElettronicaBody(models.Model):
dati_generali = DatiGenerali
dati_beni_servizi = fields.ModelListField(DatiBeniServizi)
allegati = fields.ModelListField(Allegati, null=True)
class FatturaElettronicaSemplificata(models.Model):
"""
Fattura elettronica semplificata
"""
__xmlns__ = NS10
fattura_elettronica_header = FatturaElettronicaHeader
fattura_elettronica_body = fields.ModelListField(FatturaElettronicaBody, min_num=1)
def __init__(self, *args, **kw):
super().__init__(*args, **kw)
self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = self.get_versione()
def get_versione(self):
return "FSM10"
def get_xmlattrs(self):
return {"versione": self.get_versione()}
def validate_model(self, validation):
super().validate_model(validation)
if self.get_versione() != self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione:
validation.add_error(
self.fattura_elettronica_header.dati_trasmissione._meta["formato_trasmissione"],
"formato_trasmissione should be {}".format(self.get_versione()),
code="00428")
def to_xml(self, builder):
with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b:
with b.override_default_namespace(None) as b1:
for name, field in self._meta.items():
field.to_xml(b1, getattr(self, name))
def build_etree(self, lxml=False):
"""
Build and return an ElementTree with the fattura in XML format
"""
self.fattura_elettronica_header.dati_trasmissione.formato_trasmissione = self.get_versione()
if lxml:
from a38.builder import LXMLBuilder
builder = LXMLBuilder()
else:
from a38.builder import Builder
builder = Builder()
builder.default_namespace = NS10
self.to_xml(builder)
return builder.get_tree()
def from_etree(self, el):
versione = el.attrib.get("versione", None)
if versione is None:
raise RuntimeError("root element {} misses attribute 'versione'".format(el.tag))
if versione != self.get_versione():
raise RuntimeError("root element versione is {} instead of {}".format(versione, self.get_versione()))
return super().from_etree(el)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704882565.0
a38-0.1.8/a38/fields.py 0000644 0001777 0001777 00000057275 14547470605 014576 0 ustar 00valhalla valhalla from __future__ import annotations
import base64
import datetime
import decimal
import logging
import re
import time
from decimal import Decimal
from typing import (Any, Generic, List, Optional, Sequence, Tuple, TypeVar,
Union)
import pytz
from dateutil.parser import isoparse
from . import builder, validation
from .diff import Diff
log = logging.getLogger("a38.fields")
def to_xmltag(name: str, xmlns: Optional[str] = None):
tag = "".join(x.title() for x in name.split("_"))
if xmlns is None:
return tag
return "{" + xmlns + "}" + tag
T = TypeVar("T")
class Field(Generic[T]):
"""
Description of a value that can be validated and serialized to XML.
It does not contain the value itself.
"""
# True for fields that can hold a sequence of values
multivalue = False
def __init__(self,
xmlns: Optional[str] = None,
xmltag: Optional[str] = None,
null: bool = False,
default: Optional[T] = None):
self.name: Optional[str] = None
self.xmlns = xmlns
self.xmltag = xmltag
self.null = null
self.default = default
def set_name(self, name: str):
"""
Set the field name.
Used by the Model metaclass to set the field name from the metaclass
attribute that defines it
"""
self.name = name
def get_construct_default(self) -> Optional[T]:
"""
Get the default value for when a field is constructed in the Model
constructor, and no value for it has been passed
"""
return None
def has_value(self, value: Optional[T]) -> bool:
"""
Return True if this value represents a field that has been set
"""
return value is not None
def validate(self, validation: "validation.Validation", value: Any) -> Optional[T]:
"""
Raise ValidationError(s) if the given value is not valid for this field.
Return the cleaned value.
"""
try:
value = self.clean_value(value)
except (TypeError, ValueError) as e:
validation.add_error(self, str(e))
if not self.null and not self.has_value(value):
validation.add_error(self, "missing value")
return value
def clean_value(self, value: Any) -> Optional[T]:
"""
Return a cleaned version of the given value
"""
if value is None:
return self.default
return value
def get_xmltag(self) -> str:
"""
Return the XML tag to use for this field
"""
if self.xmltag is not None:
if self.xmlns is not None:
return "{" + self.xmlns + "}" + self.xmltag
else:
return self.xmltag
if self.name is None:
raise RuntimeError("field with uninitialized name")
else:
return to_xmltag(self.name, self.xmlns)
def to_xml(self, builder: "builder.Builder", value: Optional[T]):
"""
Add this field to an XML tree
"""
value = self.clean_value(value)
if not self.has_value(value):
return
builder.add(self.get_xmltag(), self.to_str(value))
def to_jsonable(self, value: Optional[T]) -> Any:
"""
Return a json-able value for this field
"""
return self.clean_value(value)
def to_str(self, value: Optional[T]) -> str:
"""
Return this value as a string that can be parsed by clean_value
"""
return str(value)
def to_repr(self, value: Optional[T]) -> str:
"""
Return this value formatted for debugging
"""
return repr(value)
def to_python(self, value: Optional[T], **kw) -> str:
"""
Return this value as a python expression
"""
return repr(self.clean_value(value))
def from_etree(self, el):
"""
Return a value from an ElementTree Element
"""
return self.clean_value(el.text)
def diff(self, res: Diff, first: Optional[T], second: Optional[T]):
"""
Report to res if there are differences between values first and second
"""
first = self.clean_value(first)
second = self.clean_value(second)
has_first = self.has_value(first)
has_second = self.has_value(second)
if not has_first and not has_second:
return
elif has_first and not has_second:
res.add_only_first(self, first)
elif not has_first and has_second:
res.add_only_second(self, second)
elif first != second:
res.add_different(self, first, second)
class NotImplementedField(Field[None]):
"""
Field acting as a placeholder for a part of the specification that is not
yet implemented.
"""
def __init__(self, warn: bool = False, **kw):
super().__init__(**kw)
self.warn = warn
def clean_value(self, value: Any) -> None:
if self.warn:
log.warning("%s: value received: %r", self.name, value)
return None
class ChoicesField(Field[T]):
def __init__(self, choices: Sequence[T] = None, **kw):
super().__init__(**kw)
self.choices: Optional[List[Optional[T]]]
if choices is not None:
self.choices = [self.clean_value(c) for c in choices]
else:
self.choices = None
def validate(self, validation: "validation.Validation", value: Optional[T]):
value = super().validate(validation, value)
if value is not None and self.choices is not None and value not in self.choices:
validation.add_error(self, "{} is not a valid choice for this field".format(self.to_repr(value)))
return value
class ListField(Field[List[T]]):
multivalue = True
def __init__(self, field: Field[T], min_num=0, **kw):
super().__init__(**kw)
self.field = field
self.min_num = min_num
def set_name(self, name: str):
super().set_name(name)
self.field.xmltag = self.get_xmltag()
def get_construct_default(self):
res = []
for i in range(self.min_num):
res.append(None)
return res
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
res = [self.field.clean_value(val) for val in value]
while len(res) > self.min_num and not self.field.has_value(res[-1]):
res.pop()
return res
def has_value(self, value):
if value is None:
return False
for el in value:
if self.field.has_value(el):
return True
return False
def validate(self, validation, value):
value = super().validate(validation, value)
if not self.has_value(value):
return value
if len(value) < self.min_num:
validation.add_error(
self,
"list must have at least {} elements, but has only {}".format(
self.min_num, len(value)))
for idx, val in enumerate(value):
with validation.subfield(self.name + "." + str(idx)) as sub:
self.field.validate(sub, val)
return value
def to_xml(self, builder, value):
value = self.clean_value(value)
if not self.has_value(value):
return
for val in value:
self.field.to_xml(builder, val)
def to_jsonable(self, value):
value = self.clean_value(value)
if not self.has_value(value):
return None
return [self.field.to_jsonable(val) for val in value]
def to_python(self, value, **kw) -> str:
value = self.clean_value(value)
if not self.has_value(value):
return repr(None)
return "[" + ", ".join(self.field.to_python(v, **kw) for v in value) + "]"
def diff(self, res: Diff, first, second):
first = self.clean_value(first)
second = self.clean_value(second)
has_first = self.has_value(first)
has_second = self.has_value(second)
if not has_first and not has_second:
return
elif has_first and not has_second:
res.add_only_first(self, first)
elif not has_first and has_second:
res.add_only_second(self, second)
else:
for idx, (el_first, el_second) in enumerate(zip(first, second)):
with res.subfield(self.name + "." + str(idx)) as subres:
if el_first != el_second:
self.field.diff(subres, el_first, el_second)
if len(first) != len(second):
res.add_different_length(self, first, second)
def from_etree(self, elements):
values = []
for el in elements:
values.append(self.field.from_etree(el))
return values
class IntegerField(ChoicesField[int]):
def __init__(self, max_length=None, **kw):
super().__init__(**kw)
self.max_length = max_length
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
return int(value)
def validate(self, validation, value):
value = super().validate(validation, value)
if not self.has_value(value):
return value
if self.max_length is not None and len(str(value)) > self.max_length:
validation.add_error(self, "'{}' should be no more than {} digits long".format(value, self.max_length))
return value
class DecimalField(ChoicesField[Decimal]):
def __init__(
self,
max_length: Optional[int] = None,
decimals: Union[int, Tuple[int, int]] = 2,
**kw):
# Set these attributes before calling ChoicesField's __init__, since
# that will call clean_value, that needs these fields
self.max_length = max_length
if isinstance(decimals, int):
self.decimals_min = decimals
self.decimals_max = decimals
else:
self.decimals_min, self.decimals_max = decimals
super().__init__(**kw)
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
try:
dec_value = Decimal(value)
except decimal.InvalidOperation:
raise TypeError("{} cannot be converted to Decimal".format(repr(value)))
# Enforce fitting into the required range of decimal digits
sign, digits, exponent = dec_value.as_tuple()
if exponent < 0:
# We have decimal digits
if -exponent < self.decimals_min:
dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_min, rounding=decimal.ROUND_HALF_UP)
elif -exponent > self.decimals_max:
dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_max, rounding=decimal.ROUND_HALF_UP)
else:
# No decimal digits
if self.decimals_min > 0:
dec_value = dec_value.quantize(Decimal(10) ** -self.decimals_min, rounding=decimal.ROUND_HALF_UP)
return dec_value
def to_str(self, value):
if not self.has_value(value):
return "None"
return str(self.clean_value(value))
def to_jsonable(self, value):
"""
Return a json-able value for this field
"""
value = self.clean_value(value)
if not self.has_value(value):
return None
return self.to_str(value)
def validate(self, validation, value):
value = super().validate(validation, value)
if not self.has_value(value):
return value
if self.max_length is not None:
xml_value = self.to_str(value)
if len(xml_value) > self.max_length:
validation.add_error(
self,
"'{}' should be no more than {} digits long".format(xml_value, self.max_length))
return value
def diff(self, res: Diff, first: Optional[T], second: Optional[T]):
"""
Report to res if there are differences between values first and second
"""
first = self.clean_value(first)
second = self.clean_value(second)
has_first = self.has_value(first)
has_second = self.has_value(second)
if not has_first and not has_second:
return
elif has_first and not has_second:
res.add_only_first(self, first)
elif not has_first and has_second:
res.add_only_second(self, second)
elif first != second:
res.add_different(self, first, second)
class StringField(ChoicesField[str]):
def __init__(self, length=None, min_length=None, max_length=None, **kw):
super().__init__(**kw)
if length is not None:
if min_length is not None or max_length is not None:
raise ValueError("length cannot be used with min_length or max_length")
self.min_length = self.max_length = length
else:
self.min_length = min_length
self.max_length = max_length
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
return str(value)
def validate(self, validation, value):
value = super().validate(validation, value)
if not self.has_value(value):
return value
if self.min_length is not None and len(value) < self.min_length:
validation.add_error(self, "'{}' should be at least {} characters long".format(value, self.min_length))
if self.max_length is not None and len(value) > self.max_length:
validation.add_error(self, "'{}' should be no more than {} characters long".format(value, self.max_length))
return value
class Base64BinaryField(Field[bytes]):
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
if isinstance(value, bytes):
return value
if isinstance(value, str):
return base64.b64decode(value)
raise TypeError("'{}' is not an instance of str, or bytes".format(repr(value)))
def to_jsonable(self, value: Optional[T]) -> Any:
"""
Return a json-able value for this field
"""
return self.to_str(self.clean_value(value))
def to_str(self, value: Optional[T]) -> str:
"""
Return this value as a string that can be parsed by clean_value
"""
if value is None:
return None
return base64.b64encode(value).decode("utf8")
class DateField(ChoicesField[datetime.date]):
re_clean_date = re.compile(r"^\s*(\d{4}-\d{1,2}-\d{1,2})")
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
if isinstance(value, str):
mo = self.re_clean_date.match(value)
if not mo:
raise ValueError("Date '{}' does not begin with YYYY-mm-dd".format(value))
return datetime.datetime.strptime(mo.group(1), "%Y-%m-%d").date()
elif isinstance(value, datetime.datetime):
return value.date()
elif isinstance(value, datetime.date):
return value
else:
raise TypeError("'{}' is not an instance of str, datetime.date or datetime.datetime".format(repr(value)))
def to_jsonable(self, value):
"""
Return a json-able value for this field
"""
value = self.clean_value(value)
if not self.has_value(value):
return None
return self.to_str(value)
def to_str(self, value):
if value is None:
return "None"
return value.strftime("%Y-%m-%d")
class DateTimeField(ChoicesField[datetime.datetime]):
tz_rome = pytz.timezone("Europe/Rome")
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
if isinstance(value, str):
res = isoparse(value)
if res.tzinfo is None:
res = self.tz_rome.localize(res)
return res
elif isinstance(value, datetime.datetime):
if value.tzinfo is None:
return self.tz_rome.localize(value)
return value
elif isinstance(value, datetime.date):
return datetime.datetime.combine(value, datetime.time(0, 0, 0, tzinfo=self.tz_rome))
else:
raise TypeError("'{}' is not an instance of str, datetime.date or datetime.datetime".format(repr(value)))
def to_jsonable(self, value):
"""
Return a json-able value for this field
"""
value = self.clean_value(value)
if not self.has_value(value):
return None
return self.to_str(value)
def to_python(self, value, **kw):
value = self.clean_value(value)
if not self.has_value(value):
return repr(value)
return repr(value.isoformat())
def to_str(self, value):
if not self.has_value(value):
return "None"
return value.isoformat()
def to_repr(self, value):
if not self.has_value(value):
return "None"
return value.isoformat()
class ProgressivoInvioField(StringField):
CHARS = "+-./0123456789=@ABCDEFGHIJKLMNOPQRSTUVWXYZ_"
TS_RANGE = 2 ** (54 - 16)
SEQUENCE_RANGE = 2 ** 16
last_ts = None
sequence = 0
def __init__(self, **kw):
kw["max_length"] = 10
super().__init__(**kw)
def _encode_b56(self, value, places):
res = []
while value > 0:
res.append(self.CHARS[value % 43])
value //= 43
return "".join(res[::-1])
def get_construct_default(self):
ts = int(time.time())
if self.last_ts is None or self.last_ts != ts:
self.sequence = 0
self.last_ts = ts
else:
self.sequence += 1
if self.sequence > (64 ** 3):
raise OverflowError(
"Generated more than {} fatture per second, overflowing local counter".format(64 ** 3))
value = (ts << 16) + self.sequence
return self._encode_b56(value, 10)
class ModelField(Field):
"""
Field containing the structure from a Model
"""
def __init__(self, model, **kw):
super().__init__(**kw)
self.model = model
def __str__(self):
return "ModelField({})".format(self.model.__name__)
__repr__ = __str__
def get_construct_default(self):
return self.model()
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
return self.model.clean_value(value)
def has_value(self, value):
if value is None:
return False
return value.has_value()
def get_xmltag(self):
if self.xmltag is not None:
if self.xmlns is not None:
return "{" + self.xmlns + "}" + self.xmltag
else:
return self.xmltag
return self.model.get_xmltag()
def validate(self, validation, value):
value = super().validate(validation, value)
if not self.has_value(value):
return value
with validation.subfield(self.name) as sub:
value.validate_fields(sub)
value.validate_model(validation)
return value
def to_xml(self, builder, value):
value = self.clean_value(value)
if not self.has_value(value):
return
value.to_xml(builder)
def to_jsonable(self, value):
value = self.clean_value(value)
if not self.has_value(value):
return None
return value.to_jsonable()
def to_python(self, value, **kw) -> str:
value = self.clean_value(value)
if not self.has_value(value):
return repr(None)
return value.to_python(**kw)
def diff(self, res: Diff, first, second):
first = self.clean_value(first)
second = self.clean_value(second)
has_first = self.has_value(first)
has_second = self.has_value(second)
if not has_first and not has_second:
return
elif has_first and not has_second:
res.add_only_first(self, first)
elif not has_first and has_second:
res.add_only_second(self, first)
else:
with res.subfield(self.name) as subres:
first.diff(subres, second)
def from_etree(self, el):
res = self.model()
res.from_etree(el)
return res
class ModelListField(Field):
"""
Field containing a list of model instances
"""
multivalue = True
def __init__(self, model, min_num=0, **kw):
super().__init__(**kw)
self.model = model
self.min_num = min_num
def get_construct_default(self):
res = []
for i in range(self.min_num):
res.append(self.model())
return res
def clean_value(self, value):
value = super().clean_value(value)
if value is None:
return value
res = [self.model.clean_value(val) for val in value]
while len(res) > self.min_num and (res[-1] is None or not res[-1].has_value()):
res.pop()
return res
def has_value(self, value):
if value is None:
return False
for el in value:
if el.has_value():
return True
return False
def get_xmltag(self):
if self.xmltag is not None:
return self.xmltag
return self.model.get_xmltag()
def validate(self, validation, value):
value = super().validate(validation, value)
if not self.has_value(value):
return value
if len(value) < self.min_num:
validation.add_error(
self,
"list must have at least {} elements, but has only {}".format(self.min_num, len(value)))
for idx, val in enumerate(value):
with validation.subfield(self.name + "." + str(idx)) as sub:
val.validate_fields(sub)
val.validate_model(validation)
return value
def to_xml(self, builder, value):
value = self.clean_value(value)
if not self.has_value(value):
return
for val in value:
val.to_xml(builder)
def to_jsonable(self, value):
value = self.clean_value(value)
if not self.has_value(value):
return None
return [val.to_jsonable() for val in value]
def to_python(self, value, **kw) -> str:
value = self.clean_value(value)
if not self.has_value(value):
return repr(None)
return "[" + ", ".join(v.to_python(**kw) for v in value) + "]"
def diff(self, res: Diff, first, second):
first = self.clean_value(first)
second = self.clean_value(second)
has_first = self.has_value(first)
has_second = self.has_value(second)
if not has_first and not has_second:
return
if has_first and not has_second:
res.add_only_first(self, first)
elif not has_first and has_second:
res.add_only_second(self, second)
else:
for idx, (el_first, el_second) in enumerate(zip(first, second)):
with res.subfield(self.name + "." + str(idx)) as subres:
el_first.diff(subres, el_second)
if len(first) != len(second):
res.add_different_length(self, first, second)
def from_etree(self, elements):
values = []
for el in elements:
value = self.model()
value.from_etree(el)
values.append(value)
return values
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1690963938.0
a38-0.1.8/a38/models.py 0000644 0001777 0001777 00000023352 14462407742 014576 0 ustar 00valhalla valhalla from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, Optional, Tuple
from .fields import Field, ModelField
from .validation import Validation
class ModelBase:
__slots__ = ()
def __init__(self):
pass
@classmethod
def get_xmltag(cls) -> str:
xmltag = getattr(cls, "__xmltag__", None)
if xmltag is None:
xmltag = cls.__name__
xmlns = getattr(cls, "__xmlns__", None)
if xmlns:
return "{" + xmlns + "}" + xmltag
else:
return xmltag
def get_xmlattrs(self) -> Dict[str, str]:
return {}
class ModelMetaclass(type):
def __new__(cls, name, bases, dct):
_meta = {}
# Add fields from subclasses
for b in bases:
if not issubclass(b, ModelBase):
continue
b_meta = getattr(b, "_meta", None)
if b_meta is None:
continue
_meta.update(b_meta)
# Add fields from the class itself
slots = []
for field_name, val in list(dct.items()):
if isinstance(val, Field):
# Store its description in the Model _meta
_meta[field_name] = val
val.set_name(field_name)
elif isinstance(val, type) and issubclass(val, ModelBase):
# Store its description in the Model _meta
val = ModelField(val)
_meta[field_name] = val
val.set_name(field_name)
else:
# Leave untouched
continue
# Remove field_name from class variables
del dct[field_name]
# Add it as a slot in the instance
slots.append(field_name)
dct["__slots__"] = slots
res = super().__new__(cls, name, bases, dct)
res._meta = _meta
return res
class Model(ModelBase, metaclass=ModelMetaclass):
"""
Declarative description of a data structure that can be validated and
serialized to XML.
"""
__slots__ = ()
def __init__(self, *args, **kw):
super().__init__()
for name, value in zip(self._meta.keys(), args):
kw[name] = value
for name, field in self._meta.items():
value = kw.pop(name, None)
if value is None:
value = field.get_construct_default()
else:
value = field.clean_value(value)
setattr(self, name, value)
def update(self, *args, **kw):
"""
Set multiple values in the model.
Arguments are treated in the same way as in the constructor. Any field
not mentioned is left untouched.
"""
for name, value in zip(self._meta.keys(), args):
setattr(self, name, value)
for name, value in kw.items():
setattr(self, name, value)
def has_value(self):
for name, field in self._meta.items():
if field.has_value(getattr(self, name)):
return True
return False
@classmethod
def clean_value(cls, value: Any) -> Optional["Model"]:
"""
Create a model from the given value.
Always make a copy even if value is already of the right class, to
prevent mutability issues.
"""
if value is None:
return None
if isinstance(value, dict):
return cls(**value)
elif isinstance(value, ModelBase):
kw = {}
for name, field in cls._meta.items():
kw[name] = getattr(value, name, None)
return cls(**kw)
else:
raise TypeError(f"{cls.__name__}: {value!r} is {type(value).__name__}"
" instead of a Model or dict instance")
def validate_fields(self, validation: Validation):
for name, field in self._meta.items():
field.validate(validation, getattr(self, name))
def validate_model(self, validation: Validation):
pass
def validate(self, validation: Validation):
self.validate_fields(validation)
self.validate_model(validation)
def to_jsonable(self):
res = {}
for name, field in self._meta.items():
value = field.to_jsonable(getattr(self, name))
if value is not None:
res[name] = value
return res
def to_python(self, **kw) -> str:
args = []
for name, field in self._meta.items():
value = getattr(self, name)
if not field.has_value(value):
continue
args.append(name + "=" + field.to_python(value, **kw))
namespace = kw.get("namespace")
if namespace is None:
constructor = self.__class__.__module__ + "." + self.__class__.__qualname__
elif namespace is False:
constructor = self.__class__.__qualname__
else:
constructor = namespace + "." + self.__class__.__qualname__
return "{}({})".format(constructor, ", ".join(args))
def to_xml(self, builder):
with builder.element(self.get_xmltag(), **self.get_xmlattrs()) as b:
for name, field in self._meta.items():
field.to_xml(b, getattr(self, name))
def __setattr__(self, key: str, value: any):
field = self._meta.get(key, None)
if field is not None:
value = field.clean_value(value)
super().__setattr__(key, value)
def _to_tuple(self) -> Tuple[Any]:
return tuple(getattr(self, name) for name in self._meta.keys())
def __eq__(self, other):
other = self.clean_value(other)
has_self = self.has_value()
has_other = other is not None and other.has_value()
if not has_self and not has_other:
return True
if has_self != has_other:
return False
return self._to_tuple() == other._to_tuple()
def __ne__(self, other):
other = self.clean_value(other)
has_self = self.has_value()
has_other = other is not None and other.has_value()
if not has_self and not has_other:
return False
if has_self != has_other:
return True
return self._to_tuple() != other._to_tuple()
def __lt__(self, other):
other = self.clean_value(other)
has_self = self.has_value()
has_other = other is not None and other.has_value()
if not has_self and not has_other:
return False
if has_self and not has_other:
return False
if not has_self and has_other:
return True
return self._to_tuple() < other._to_tuple()
def __gt__(self, other):
other = self.clean_value(other)
has_self = self.has_value()
has_other = other is not None and other.has_value()
if not has_self and not has_other:
return False
if has_self and not has_other:
return True
if not has_self and has_other:
return False
return self._to_tuple() > other._to_tuple()
def __le__(self, other):
other = self.clean_value(other)
has_self = self.has_value()
has_other = other is not None and other.has_value()
if not has_self and not has_other:
return True
if has_self and not has_other:
return False
if not has_self and has_other:
return True
return self._to_tuple() <= other._to_tuple()
def __ge__(self, other):
other = self.clean_value(other)
has_self = self.has_value()
has_other = other is not None and other.has_value()
if not has_self and not has_other:
return True
if has_self and not has_other:
return True
if not has_self and has_other:
return False
return self._to_tuple() >= other._to_tuple()
def __str__(self):
vals = []
for name, field in self._meta.items():
vals.append(name + "=" + field.to_str(getattr(self, name)))
return "{}({})".format(self.__class__.__name__, ", ".join(vals))
def __repr__(self):
vals = []
for name, field in self._meta.items():
vals.append(name + "=" + field.to_str(getattr(self, name)))
return "{}({})".format(self.__class__.__name__, ", ".join(vals))
def from_etree(self, el):
if el.tag != self.get_xmltag():
raise RuntimeError("element is {} instead of {}".format(el.tag, self.get_xmltag()))
tag_map = {field.get_xmltag(): (name, field) for name, field in self._meta.items()}
# Group values by tag
by_name = defaultdict(list)
for child in el:
try:
name, field = tag_map[child.tag]
except KeyError:
raise RuntimeError("found unexpected element {} in {}".format(child.tag, el.tag))
by_name[name].append(child)
for name, elements in by_name.items():
field = self._meta[name]
if field.multivalue:
setattr(self, name, field.from_etree(elements))
elif len(elements) != 1:
raise RuntimeError(
"found {} {} elements in {} instead of just 1".format(
len(elements), child.tag, el.tag))
else:
setattr(self, name, field.from_etree(elements[0]))
def diff(self, diff, other):
has_self = self.has_value()
has_other = other.has_value()
if not has_self and not has_other:
return
if has_self != has_other:
diff.add(None, self, other)
return
for name, field in self._meta.items():
first = getattr(self, name)
second = getattr(other, name)
field.diff(diff, first, second)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/render.py 0000644 0001777 0001777 00000005162 14373374076 014575 0 ustar 00valhalla valhalla import os
import subprocess
import tempfile
from typing import Optional
try:
import lxml.etree
HAVE_LXML = True
except ModuleNotFoundError:
HAVE_LXML = False
if HAVE_LXML:
class XSLTTransform:
def __init__(self, xslt):
parsed_xslt = lxml.etree.parse(xslt)
self.xslt = lxml.etree.XSLT(parsed_xslt)
def __call__(self, f):
"""
Return the ElementTree for f rendered as HTML
"""
tree = f.build_etree(lxml=True)
return self.xslt(tree)
def _requires_enable_local_file_access(self, wkhtmltopdf: str):
"""
Check if we need to pass --enable-local-file-access to wkhtmltopdf.
See https://github.com/Truelite/python-a38/issues/6 for details
"""
# We need to specifically use --extended-help, because --help does
# not always document --enable-local-file-access
verifyLocalAccessToFileOption = subprocess.run(
[wkhtmltopdf, "--extended-help"], stdin=subprocess.DEVNULL, text=True, capture_output=True)
return "--enable-local-file-access" in verifyLocalAccessToFileOption.stdout
def to_pdf(self, wkhtmltopdf: str, f, output_file: Optional[str] = None):
"""
Render a fattura to PDF using the given wkhtmltopdf command.
Returns None if output_file is given, or the binary PDF data if not
"""
if output_file is None:
output_file = "-"
html = self(f)
# TODO: pass html data as stdin, using '-' as input for
# wkhtmltopdf: that currently removes the requirement for
# --enable-local-file-access
with tempfile.NamedTemporaryFile("wb", suffix=".html", delete=False) as fd:
html.write(fd)
tempFilename = fd.name
try:
cmdLine = [wkhtmltopdf, tempFilename, output_file]
if self._requires_enable_local_file_access(wkhtmltopdf):
cmdLine.insert(1, "--enable-local-file-access")
res = subprocess.run(cmdLine, stdin=subprocess.DEVNULL, capture_output=True)
if res.returncode != 0:
raise RuntimeError(
"{0} exited with error {1}: stderr: {2!r}".format(
wkhtmltopdf, res.returncode, res.stderr))
if output_file == "-":
return res.stdout
else:
return None
finally:
os.remove(tempFilename)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704882565.0
a38-0.1.8/a38/traversal.py 0000644 0001777 0001777 00000001731 14547470605 015315 0 ustar 00valhalla valhalla from contextlib import contextmanager
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from . import fields
class Annotation:
def __init__(self, prefix: Optional[str], field: "fields.Field"):
self.prefix = prefix
self.field = field
@property
def qualified_field(self) -> str:
if self.prefix is None:
return self.field.name
elif self.field.name is None:
return self.prefix
else:
return self.prefix + "." + self.field.name
class Traversal:
def __init__(self, prefix: Optional[str] = None):
self.prefix = prefix
def with_prefix(self, prefix: str) -> "Traversal":
raise NotImplementedError("Traversal subclasses must implement with_prefix")
@contextmanager
def subfield(self, name: str):
if self.prefix is None:
prefix = name
else:
prefix = self.prefix + "." + name
yield self.with_prefix(prefix)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/trustedlist.py 0000644 0001777 0001777 00000022716 14373374076 015710 0 ustar 00valhalla valhalla import base64
import logging
import re
import subprocess
try:
from defusedxml import ElementTree as ET
except ModuleNotFoundError:
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
from typing import Dict
from cryptography import x509
from . import fields, models
log = logging.getLogger("__name__")
NS = "http://uri.etsi.org/02231/v2#"
NS_XMLDSIG = "http://www.w3.org/2000/09/xmldsig#"
NS_ADDTYPES = "http://uri.etsi.org/02231/v2/additionaltypes#"
class OtherInformation(models.Model):
__xmlns__ = NS
tsl_type = fields.NotImplementedField(xmltag="TSLType", xmlns=NS)
scheme_territory = fields.StringField(null=True, xmlns=NS)
mime_type = fields.StringField(null=True, xmlns=NS_ADDTYPES)
scheme_operator_name = fields.NotImplementedField(xmlns=NS)
scheme_type_community_rules = fields.NotImplementedField(xmlns=NS)
class AdditionalInformation(models.Model):
__xmlns__ = NS
other_information = fields.ModelListField(OtherInformation)
class OtherTSLPointer(models.Model):
__xmlns__ = NS
tsl_location = fields.StringField(xmltag="TSLLocation", xmlns=NS)
service_digital_identities = fields.NotImplementedField(xmlns=NS)
additional_information = AdditionalInformation
class PointersToOtherTSL(models.Model):
__xmlns__ = NS
other_tsl_pointer = fields.ModelListField(OtherTSLPointer)
class SchemeInformation(models.Model):
__xmlns__ = NS
pointers_to_other_tsl = fields.ModelField(PointersToOtherTSL)
tsl_version_identifier = fields.NotImplementedField(xmltag="TSLVersionIdentifier", xmlns=NS)
tsl_sequence_number = fields.NotImplementedField(xmltag="TSLSequenceNumber", xmlns=NS)
tsl_type = fields.NotImplementedField(xmltag="TSLType", xmlns=NS)
scheme_operator_name = fields.NotImplementedField(xmlns=NS)
scheme_operator_address = fields.NotImplementedField(xmlns=NS)
scheme_information_uri = fields.NotImplementedField(xmltag="SchemeInformationURI", xmlns=NS)
scheme_name = fields.NotImplementedField(xmlns=NS)
status_determination_approach = fields.NotImplementedField(xmlns=NS)
scheme_type_community_rules = fields.NotImplementedField(xmlns=NS)
scheme_territory = fields.NotImplementedField(xmlns=NS)
policy_or_legal_notice = fields.NotImplementedField(xmlns=NS)
historical_information_period = fields.NotImplementedField(xmlns=NS)
list_issue_date_time = fields.NotImplementedField(xmlns=NS)
next_update = fields.NotImplementedField(xmlns=NS)
distribution_points = fields.NotImplementedField(xmlns=NS)
class TSPInformation(models.Model):
__xmlns__ = NS
tsp_name = fields.NotImplementedField(xmltag="TSPName", xmlns=NS)
tsp_trade_name = fields.NotImplementedField(xmltag="TSPTradeName", xmlns=NS)
tsp_address = fields.NotImplementedField(xmltag="TSPAddress", xmlns=NS)
tsp_information_url = fields.NotImplementedField(xmltag="TSPInformationURI", xmlns=NS)
class DigitalId(models.Model):
__xmlns__ = NS
x509_subject_name = fields.StringField(xmltag="X509SubjectName", xmlns=NS, null=True)
x509_ski = fields.StringField(xmltag="X509SKI", xmlns=NS, null=True)
x509_certificate = fields.StringField(xmltag="X509Certificate", xmlns=NS, null=True)
class ServiceDigitalIdentity(models.Model):
__xmlns__ = NS
digital_id = fields.ModelListField(DigitalId)
class ServiceInformation(models.Model):
__xmlns__ = NS
service_type_identifier = fields.StringField(xmlns=NS)
service_name = fields.NotImplementedField(xmlns=NS)
service_digital_identity = ServiceDigitalIdentity
service_status = fields.StringField(xmlns=NS)
status_starting_time = fields.NotImplementedField(xmlns=NS)
service_information_extensions = fields.NotImplementedField(xmlns=NS)
class TSPService(models.Model):
__xmlns__ = NS
service_information = ServiceInformation
service_history = fields.NotImplementedField(xmlns=NS)
class TSPServices(models.Model):
__xmlns__ = NS
tsp_service = fields.ModelListField(TSPService)
class TrustServiceProvider(models.Model):
__xmlns__ = NS
tsp_information = TSPInformation
tsp_services = TSPServices
class TrustServiceProviderList(models.Model):
__xmlns__ = NS
trust_service_provider = fields.ModelListField(TrustServiceProvider)
class TrustServiceStatusList(models.Model):
__xmlns__ = NS
scheme_information = SchemeInformation
signature = fields.NotImplementedField(xmlns=NS_XMLDSIG)
trust_service_provider_list = TrustServiceProviderList
def get_tsl_pointer_by_territory(self, territory):
for other_tsl_pointer in self.scheme_information.pointers_to_other_tsl.other_tsl_pointer:
territory = None
for oi in other_tsl_pointer.additional_information.other_information:
if oi.scheme_territory is not None:
territory = oi.scheme_territory
break
if territory != "IT":
continue
return other_tsl_pointer.tsl_location
def auto_from_etree(root):
expected_tag = "{{{}}}TrustServiceStatusList".format(NS)
if root.tag != expected_tag:
raise RuntimeError("Root element {} is not {}".format(root.tag, expected_tag))
res = TrustServiceStatusList()
res.from_etree(root)
return res
def load_url(url: str):
"""
Return a TrustedServiceStatusList instance from the XML downloaded from the
given URL
"""
import requests
res = requests.get(url)
res.raise_for_status()
root = ET.fromstring(res.content)
return auto_from_etree(root)
def load_certs() -> Dict[str, x509.Certificate]:
"""
Download trusted list certificates for Italy, parse them and return a dict
mapping certificate names good for use as file names to cryptography.x509
certificates
"""
re_clean_fname = re.compile(r"[^A-Za-z0-9_-]")
eu_url = "https://ec.europa.eu/information_society/policy/esignature/trusted-list/tl-mp.xml"
log.info("Downloading EU index from %s", eu_url)
eu_tl = load_url(eu_url)
it_url = eu_tl.get_tsl_pointer_by_territory("IT")
log.info("Downloading IT data from %s", it_url)
trust_service_status_list = load_url(it_url)
by_name = defaultdict(list)
for tsp in trust_service_status_list.trust_service_provider_list.trust_service_provider:
for tsp_service in tsp.tsp_services.tsp_service:
si = tsp_service.service_information
if si.service_status not in (
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/recognisedatnationallevel",
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/granted"):
continue
if si.service_type_identifier not in (
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",):
continue
# print("identifier", si.service_type_identifier)
# print("status", si.service_status)
cert = []
sn = []
for di in si.service_digital_identity.digital_id:
if di.x509_subject_name is not None:
sn.append(di.x509_subject_name)
# if di.x509_ski is not None:
# print(" SKI:", di.x509_ski)
if di.x509_certificate is not None:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
der = base64.b64decode(di.x509_certificate)
cert.append(x509.load_der_x509_certificate(der, default_backend()))
if len(cert) == 0:
raise RuntimeError("{} has no certificates".format(sn))
elif len(cert) > 1:
raise RuntimeError("{} has {} certificates".format(sn, len(cert)))
else:
from cryptography.x509.oid import NameOID
cert = cert[0]
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
# print("sn", sn)
# print(cert)
# print("full cn", cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME))
# print("cn", cn)
fname = re_clean_fname.sub("_", cn)
by_name[fname].append(cert)
res = {}
for name, certs in by_name.items():
if len(certs) == 1:
if name in res:
raise RuntimeError("{} already in result".format(name))
res[name] = certs[0]
else:
for idx, cert in enumerate(certs, start=1):
idxname = name + "_a38_{}".format(idx)
if idxname in res:
raise RuntimeError("{} already in result".format(name))
res[idxname] = cert
return res
def update_capath(destdir: Path, remove_old=False):
from cryptography.hazmat.primitives import serialization
certs = load_certs()
if destdir.is_dir():
current = set(c.name for c in destdir.iterdir() if c.name.endswith(".crt"))
else:
current = set()
destdir.mkdir(parents=True)
for name, cert in certs.items():
fname = name + ".crt"
current.discard(fname)
pathname = destdir / fname
with pathname.open(mode="wb") as fd:
fd.write(cert.public_bytes(serialization.Encoding.PEM))
log.info("%s: written", pathname)
if remove_old:
for fname in current:
pathname = destdir / fname
pathname.unlink()
log.info("%s: removed", pathname)
subprocess.run(["openssl", "rehash", destdir], check=True)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38/validation.py 0000644 0001777 0001777 00000003626 14373374076 015453 0 ustar 00valhalla valhalla from typing import List, Optional, Sequence, Union
from . import fields
from .traversal import Annotation, Traversal
class ValidationError(Annotation):
def __init__(self, prefix: Optional[str], field: "fields.Field", msg: str, code: str = None):
self.prefix = prefix
self.field = field
self.msg = msg
self.code = code
def __str__(self):
if self.code is not None:
return "{}: [{}] {}".format(self.qualified_field, self.code, self.msg)
else:
return "{}: {}".format(self.qualified_field, self.msg)
Fields = Union["fields.Field", Sequence["fields.Field"]]
class Validation(Traversal):
def __init__(self,
prefix: Optional[str] = None,
warnings: Optional[List[ValidationError]] = None,
errors: Optional[List[ValidationError]] = None):
super().__init__(prefix)
self.warnings: List[ValidationError]
self.errors: List[ValidationError]
if warnings is None:
self.warnings = []
else:
self.warnings = warnings
if errors is None:
self.errors = []
else:
self.errors = errors
def with_prefix(self, prefix: str):
return Validation(prefix, self.warnings, self.errors)
def add_warning(self, field: Fields, msg: str, code: str = None):
if isinstance(field, fields.Field):
self.warnings.append(ValidationError(self.prefix, field, msg, code))
else:
for f in field:
self.warnings.append(ValidationError(self.prefix, f, msg, code))
def add_error(self, field: Fields, msg: str, code: str = None):
if isinstance(field, fields.Field):
self.errors.append(ValidationError(self.prefix, field, msg, code))
else:
for f in field:
self.errors.append(ValidationError(self.prefix, f, msg, code))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3214443
a38-0.1.8/a38.egg-info/ 0000755 0001777 0001777 00000000000 14547515512 014425 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704893258.0
a38-0.1.8/a38.egg-info/PKG-INFO 0000644 0001777 0001777 00000016073 14547515512 015531 0 ustar 00valhalla valhalla Metadata-Version: 2.1
Name: a38
Version: 0.1.8
Summary: parse and generate Italian Fattura Elettronica
Home-page: https://github.com/Truelite/python-a38/
Author: Enrico Zini
Author-email: enrico@truelite.it
License: https://www.apache.org/licenses/LICENSE-2.0.html
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: cacerts
Provides-Extra: formatted_python
Provides-Extra: html
License-File: LICENSE
# Python A38

Library to generate Italian Fattura Elettronica from Python.
This library implements a declarative data model similar to Django models, that
is designed to describe, validate, serialize and parse Italian Fattura
Elettronica data.
Only part of the specification is implemented, with more added as needs will
arise. You are welcome to implement the missing pieces you need and send a pull
request: the idea is to have a good, free (as in freedom) library to make
billing in Italy with Python easier for everyone.
The library can generate various kinds of fatture that pass validation, and can
parse all the example XML files distributed by
[fatturapa.gov.it](https://www.fatturapa.gov.it/it/lafatturapa/esempi/)
## Dependencies
Required: dateutil, pytz, asn1crypto, and the python3 standard library.
Optional:
* yapf for formatting `a38tool python` output
* lxml for rendering to HTML
* the wkhtmltopdf command for rendering to PDF
* requests for downloading CA certificates for signature verification
## `a38tool` script
A simple command line wrapper to the library functions is available as `a38tool`:
```text
$ a38tool --help
usage: a38tool [-h] [--verbose] [--debug]
{json,xml,python,diff,validate,html,pdf,update_capath} ...
Handle fattura elettronica files
positional arguments:
{json,xml,python,diff,validate,html,pdf,update_capath}
actions
json output a fattura in JSON
xml output a fattura in XML
python output a fattura as Python code
diff show the difference between two fatture
validate validate the contents of a fattura
html render a Fattura as HTML using a .xslt stylesheet
pdf render a Fattura as PDF using a .xslt stylesheet
update_capath create/update an openssl CApath with CA certificates
that can be used to validate digital signatures
optional arguments:
-h, --help show this help message and exit
--verbose, -v verbose output
--debug debug output
```
See [a38tool.md](a38tool.md) for more details.
## Example code
```py
import a38
from a38.validation import Validation
import datetime
import sys
cedente_prestatore = a38.CedentePrestatore(
a38.DatiAnagraficiCedentePrestatore(
a38.IdFiscaleIVA("IT", "01234567890"),
codice_fiscale="NTNBLN22C23A123U",
anagrafica=a38.Anagrafica(denominazione="Test User"),
regime_fiscale="RF01",
),
a38.Sede(indirizzo="via Monferrato", numero_civico="1", cap="50100", comune="Firenze", provincia="FI", nazione="IT"),
iscrizione_rea=a38.IscrizioneREA(
ufficio="FI",
numero_rea="123456",
stato_liquidazione="LN",
),
contatti=a38.Contatti(email="local_part@pec_domain.it"),
)
cessionario_committente = a38.CessionarioCommittente(
a38.DatiAnagraficiCessionarioCommittente(
a38.IdFiscaleIVA("IT", "76543210987"),
anagrafica=a38.Anagrafica(denominazione="A Company SRL"),
),
a38.Sede(indirizzo="via Langhe", numero_civico="1", cap="50142", comune="Firenze", provincia="FI", nazione="IT"),
)
bill_number = 1
f = a38.FatturaPrivati12()
f.fattura_elettronica_header.dati_trasmissione.id_trasmittente = a38.IdTrasmittente("IT", "10293847561")
f.fattura_elettronica_header.dati_trasmissione.codice_destinatario = "FUFUFUF"
f.fattura_elettronica_header.cedente_prestatore = cedente_prestatore
f.fattura_elettronica_header.cessionario_committente = cessionario_committente
body = f.fattura_elettronica_body[0]
body.dati_generali.dati_generali_documento = a38.DatiGeneraliDocumento(
tipo_documento="TD01",
divisa="EUR",
data=datetime.date.today(),
numero=bill_number,
causale=["Test billing"],
)
body.dati_beni_servizi.add_dettaglio_linee(
descrizione="Test item", quantita=2, unita_misura="kg",
prezzo_unitario="25.50", aliquota_iva="22.00")
body.dati_beni_servizi.add_dettaglio_linee(
descrizione="Other item", quantita=1, unita_misura="kg",
prezzo_unitario="15.50", aliquota_iva="22.00")
body.dati_beni_servizi.build_dati_riepilogo()
body.build_importo_totale_documento()
res = Validation()
f.validate(res)
if res.warnings:
for w in res.warnings:
print(str(w), file=sys.stderr)
if res.errors:
for e in res.errors:
print(str(e), file=sys.stderr)
filename = "{}{}_{:05d}.xml".format(
f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_paese,
f.fattura_elettronica_header.cedente_prestatore.dati_anagrafici.id_fiscale_iva.id_codice,
bill_number)
tree = f.build_etree()
with open(filename, "wb") as out:
tree.write(out, encoding="utf-8", xml_declaration=True)
```
# Digital signatures
Digital signatures on Firma Elettronica are
[CAdES](https://en.wikipedia.org/wiki/CAdES_(computing)) signatures.
openssl cal verify the signatures, but not yet generate them. A patch to sign
with CAdES [has been recently merged](https://github.com/openssl/openssl/commit/e85d19c68e7fb3302410bd72d434793e5c0c23a0)
but not yet released as of 2019-02-26.
## Downloading CA certificates
CA certificates for validating digital certificates are
[distributed by the EU in XML format](https://ec.europa.eu/cefdigital/wiki/display/cefdigital/esignature).
See also [the AGID page about it](https://www.agid.gov.it/it/piattaforme/firma-elettronica-qualificata/certificati).
There is a [Trusted List Browser](https://webgate.ec.europa.eu/tl-browser/) but
apparently no way of getting a simple bundle of certificates useable by
openssl.
`a38tool` has basic features to download and parse CA certificate information,
and maintain a CA certificate directory:
```
a38tool update_capath certdir/ --remove-old
```
No particular effort is made to validate the downloaded certificates, besides
the standard HTTPS checks performed by the [requests
library](http://docs.python-requests.org/en/master/).
## Verifying signed `.p7m` files
Once you have a CA certificate directory, verifying signed p7m files is quite
straightforward:
```
openssl cms -verify -in tests/data/test.txt.p7m -inform der -CApath certs/
```
# Useful links
XSLT stylesheets for displaying fatture:
* From [fatturapa.gov.it](https://www.fatturapa.gov.it/),
among the [FatturaPA resources](https://www.fatturapa.gov.it/it/norme-e-regole/documentazione-fattura-elettronica/formato-fatturapa/index.html)
* From [AssoSoftware](http://www.assosoftware.it/allegati/assoinvoice/FoglioStileAssoSoftware.zip)
# Copyright
Copyright 2019-2024 Truelite S.r.l.
This software is released under the Apache License 2.0
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704893258.0
a38-0.1.8/a38.egg-info/SOURCES.txt 0000644 0001777 0001777 00000001514 14547515512 016312 0 ustar 00valhalla valhalla .gitignore
CHANGELOG.md
LICENSE
MANIFEST.in
Makefile
README.md
a38tool
a38tool.md
document-a38
download-docs
publiccode.yml
requirements-devops.txt
requirements-lib.txt
setup.cfg
setup.py
test-coverage
.github/workflows/py.yml
a38/__init__.py
a38/builder.py
a38/codec.py
a38/consts.py
a38/crypto.py
a38/diff.py
a38/fattura.py
a38/fattura_semplificata.py
a38/fields.py
a38/models.py
a38/render.py
a38/traversal.py
a38/trustedlist.py
a38/validation.py
a38.egg-info/PKG-INFO
a38.egg-info/SOURCES.txt
a38.egg-info/dependency_links.txt
a38.egg-info/requires.txt
a38.egg-info/top_level.txt
doc/.gitignore
doc/README.md
stubs/__init__.pyi
stubs/dateutil/__init__.pyi
stubs/dateutil/parser.pyi
tests/test_fattura.py
tests/test_fields.py
tests/test_models.py
tests/test_p7m.py
tests/data/dati_trasporto.xml
tests/data/test.txt.p7m
tests/data/unicode.xml ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704893258.0
a38-0.1.8/a38.egg-info/dependency_links.txt 0000644 0001777 0001777 00000000001 14547515512 020473 0 ustar 00valhalla valhalla
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704893258.0
a38-0.1.8/a38.egg-info/requires.txt 0000644 0001777 0001777 00000000145 14547515512 017025 0 ustar 00valhalla valhalla asn1crypto
defusedxml
python-dateutil
pytz
[cacerts]
requests
[formatted_python]
yapf
[html]
lxml
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704893258.0
a38-0.1.8/a38.egg-info/top_level.txt 0000644 0001777 0001777 00000000004 14547515512 017151 0 ustar 00valhalla valhalla a38
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1704882565.0
a38-0.1.8/a38tool 0000755 0001777 0001777 00000042050 14547470605 013563 0 ustar 00valhalla valhalla #!/usr/bin/python3
from __future__ import annotations
import argparse
import contextlib
import fnmatch
import logging
import os.path
import re
import shutil
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Optional, IO, Union
from a38 import codec, models
if TYPE_CHECKING:
import fattura
from .fattura import Fattura
from .fattura_semplificata import FatturaElettronicaSemplificata
log = logging.getLogger("a38tool")
class Fail(Exception):
pass
class App:
NAME = None
def __init__(self, args):
self.args = args
def load_fattura(self, pathname) -> Union[Fattura, FatturaElettronicaSemplificata]:
codecs = codec.Codecs()
codec_cls = codecs.codec_from_filename(pathname)
return codec_cls().load(pathname)
@classmethod
def add_subparser(cls, subparsers):
name = getattr(cls, "NAME", None)
if name is None:
name = cls.__name__.lower()
parser = subparsers.add_parser(name, help=cls.__doc__.strip())
parser.set_defaults(app=cls)
return parser
class Diff(App):
"""
show the difference between two fatture
"""
NAME = "diff"
def __init__(self, args):
super().__init__(args)
self.first = args.first
self.second = args.second
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument("first", help="first input file (.xml or .xml.p7m)")
parser.add_argument("second", help="second input file (.xml or .xml.p7m)")
return parser
def run(self):
first = self.load_fattura(self.first)
second = self.load_fattura(self.second)
from a38.diff import Diff
res = Diff()
first.diff(res, second)
if res.differences:
for d in res.differences:
print(d)
return 1
class Validate(App):
"""
validate the contents of a fattura
"""
NAME = "validate"
def __init__(self, args):
super().__init__(args)
self.pathname = args.file
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument("file", help="input file (.xml or .xml.p7m)")
return parser
def run(self):
f = self.load_fattura(self.pathname)
from a38.validation import Validation
res = Validation()
f.validate(res)
if res.warnings:
for w in res.warnings:
print(str(w), file=sys.stderr)
if res.errors:
for e in res.errors:
print(str(e), file=sys.stderr)
return 1
class Exporter(App):
def __init__(self, args):
super().__init__(args)
self.files = args.files
self.output = args.output
self.codec = self.get_codec()
def get_codec(self) -> codec.Codec:
"""
Instantiate the output codec to use for this exporter
"""
raise NotImplementedError(
f"{self.__class__.__name__}.get_codec is not implemented"
)
def write(self, f: models.Model, file: Union[IO[str], IO[bytes]]):
self.codec.write_file(f, file)
@contextlib.contextmanager
def open_output(self):
if self.output is None:
if self.codec.binary:
yield sys.stdout.buffer
else:
yield sys.stdout
else:
with open(self.output, "wb" if self.codec.binary else "wt") as out:
yield out
def run(self):
with self.open_output() as out:
for pathname in self.files:
f = self.load_fattura(pathname)
self.write(f, out)
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument(
"-o", "--output", help="output file (default: standard output)"
)
parser.add_argument("files", nargs="+", help="input files (.xml or .xml.p7m)")
return parser
class ExportJSON(Exporter):
"""
output a fattura in JSON
"""
NAME = "json"
def get_codec(self) -> codec.Codec:
if self.args.indent == "no":
indent = None
else:
try:
indent = int(self.args.indent)
except ValueError:
raise Fail("--indent argument must be an integer on 'no'")
return codec.JSON(indent=indent)
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument(
"--indent",
default="1",
help="indentation space (default: 1, use 'no' for all in one line)",
)
return parser
class ExportYAML(Exporter):
"""
output a fattura in JSON
"""
NAME = "yaml"
def get_codec(self) -> codec.Codec:
return codec.YAML()
class ExportXML(Exporter):
"""
output a fattura in XML
"""
NAME = "xml"
def get_codec(self) -> codec.Codec:
return codec.XML()
class ExportPython(Exporter):
"""
output a fattura as Python code
"""
NAME = "python"
def get_codec(self) -> codec.Codec:
namespace = self.args.namespace
if namespace == "":
namespace = False
return codec.Python(namespace=namespace, unformatted=self.args.unformatted)
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument(
"--namespace",
default=None,
help="namespace to use for the model classes (default: the module fully qualified name)",
)
parser.add_argument(
"--unformatted",
action="store_true",
help="disable code formatting, outputting a single-line statement",
)
return parser
class Edit(App):
"""
Open a fattura for modification in a text editor
"""
def __init__(self, args):
super().__init__(args)
if self.args.style == "yaml":
self.edit_codec = codec.YAML()
elif self.args.style == "python":
self.edit_codec = codec.Python(loadable=True)
else:
raise Fail(f"Unsupported edit style {self.args.style!r}")
def write_out(self, f):
"""
Write a fattura, as much as possible over the file being edited
"""
codecs = codec.Codecs()
codec_cls = codecs.codec_from_filename(self.args.file)
if codec_cls == codec.P7M:
with open(self.args.file[:-4], "wb") as fd:
codec_cls().write_file(f, fd)
elif codec_cls.binary:
with open(self.args.file, "wb") as fd:
codec_cls().write_file(f, fd)
else:
with open(self.args.file, "wt") as fd:
codec_cls().write_file(f, fd)
def run(self):
f = self.load_fattura(self.args.file)
f1 = self.edit_codec.interactive_edit(f)
if f1 is not None and f != f1:
self.write_out(f1)
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument(
"-s",
"--style",
default="yaml",
help="editable representation to use, one of 'yaml' or 'python'. Default: $(default)s",
)
parser.add_argument("file", help="file to edit")
return parser
class Renderer(App):
"""
Base class for CLI commands that render a Fattura
"""
def __init__(self, args):
from a38.render import HAVE_LXML
if not HAVE_LXML:
raise Fail("python3-lxml is needed for XSLT based rendering")
super().__init__(args)
self.stylesheet = args.stylesheet
self.files = args.files
self.output = args.output
self.force = args.force
from a38.render import XSLTTransform
self.transform = XSLTTransform(self.stylesheet)
def render(self, f, output: str):
"""
Render the Fattura to the given destination file
"""
raise NotImplementedError(
self.__class__.__name__ + ".render is not implemented"
)
def run(self):
for pathname in self.files:
dirname = os.path.normpath(os.path.dirname(pathname))
basename = os.path.basename(pathname)
basename, ext = os.path.splitext(basename)
output = self.output.format(dirname=dirname, basename=basename, ext=ext)
if not self.force and os.path.exists(output):
log.warning(
"%s: output file %s already exists: skipped", pathname, output
)
else:
log.info("%s: writing %s", pathname, output)
f = self.load_fattura(pathname)
self.render(f, output)
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument(
"-f", "--force", action="store_true", help="overwrite existing output files"
)
default_output = "{dirname}/{basename}{ext}." + cls.NAME
parser.add_argument(
"-o",
"--output",
default=default_output,
help="output file; use {dirname} for the source file path,"
" {basename} for the source file name"
" (default: '" + default_output + "'",
)
parser.add_argument(
"stylesheet", help=".xsl/.xslt stylesheet file to use for rendering"
)
parser.add_argument("files", nargs="+", help="input files (.xml or .xml.p7m)")
return parser
class RenderHTML(Renderer):
"""
render a Fattura as HTML using a .xslt stylesheet
"""
NAME = "html"
def render(self, f, output):
html = self.transform(f)
html.write(output)
class RenderPDF(Renderer):
"""
render a Fattura as PDF using a .xslt stylesheet
"""
NAME = "pdf"
def __init__(self, args):
super().__init__(args)
self.wkhtmltopdf = shutil.which("wkhtmltopdf")
if self.wkhtmltopdf is None:
raise Fail("wkhtmltopdf is needed for PDF rendering")
def render(self, f, output: str):
self.transform.to_pdf(self.wkhtmltopdf, f, output)
class UpdateCAPath(App):
"""
create/update an openssl CApath with CA certificates that can be used to
validate digital signatures
"""
NAME = "update_capath"
def __init__(self, args):
super().__init__(args)
self.destdir = Path(args.destdir)
self.remove_old = args.remove_old
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument("destdir", help="CA certificate directory to update")
parser.add_argument(
"--remove-old", action="store_true", help="remove old certificates"
)
return parser
def run(self):
from a38 import trustedlist as tl
tl.update_capath(self.destdir, remove_old=self.remove_old)
class Allegati(App):
"""
Show the attachments in the fattura
"""
def __init__(self, args: argparse.Namespace) -> None:
super().__init__(args)
self.pathname = args.file
self.ids: set[int] = set()
self.globs: list[re.Pattern] = []
self.has_filter = False
for pattern in self.args.attachments:
self.has_filter = True
if pattern.isdigit():
self.ids.add(int(pattern))
elif pattern.startswith("^"):
self.globs.append(re.compile(pattern))
else:
self.globs.append(re.compile(fnmatch.translate(pattern)))
@classmethod
def add_subparser(cls, subparsers):
parser = super().add_subparser(subparsers)
parser.add_argument(
"--extract", "-x", action="store_true", help="extract selected attachments"
)
parser.add_argument(
"--json", action="store_true", help="show attachments in json format"
)
parser.add_argument(
"--yaml", action="store_true", help="show attachments in yaml format"
)
parser.add_argument(
"--output",
"-o",
action="store",
help="destination file name (-o file) or directory (-o dir/)",
)
parser.add_argument("file", help="input file (.xml or .xml.p7m)")
parser.add_argument(
"attachments",
nargs="*",
help="IDs or names of attachments to extract. Shell-like wildcards allowed, or regexps if starting with ^",
)
return parser
def match_allegato(self, index: int, allegato: fattura.Allegati) -> bool:
"""
Check if the given allegato matches the attachments patterns
"""
if not self.has_filter:
return True
for id in self.ids:
if index == id:
return True
for regex in self.globs:
if regex.match(allegato.nome_attachment):
return True
return False
def print_allegato(self, index: int, allegato: fattura.Allegati) -> None:
formato = allegato.formato_attachment or "-"
print(f"{index:02d}: {formato} {allegato.nome_attachment}")
if allegato.descrizione_attachment:
print(f" {allegato.descrizione_attachment}")
def run(self):
f = self.load_fattura(self.pathname)
selected: list[tuple[int, fattura.Allegati]] = []
index = 1
for body in f.fattura_elettronica_body:
for allegato in body.allegati:
if self.match_allegato(index, allegato):
selected.append((index, allegato))
index += 1
if self.args.json or self.args.yaml:
output = []
for index, allegato in selected:
jsonable = {"index": index}
jsonable.update(allegato.to_jsonable())
jsonable.pop("attachment", None)
output.append(jsonable)
if self.args.json:
import json
json.dump(output, sys.stdout, indent=2)
print()
else:
import yaml
yaml.dump(
output,
stream=sys.stdout,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
explicit_start=True,
Dumper=yaml.CDumper,
)
elif self.args.extract:
destname: Optional[str]
destdir: str
if self.args.output:
if os.path.isdir(self.args.output) or self.args.output.endswith(os.sep):
destname = None
destdir = self.args.output
else:
destname = self.args.output
destdir = "."
else:
destname = None
destdir = "."
if destname is not None and len(selected) > 1:
raise Fail(
"there are multiple attachment to save, and--output points to a single file name"
)
os.makedirs(destdir, exist_ok=True)
for index, allegato in selected:
if destname is None:
destname = os.path.basename(allegato.nome_attachment)
dest = os.path.join(destdir, destname)
log.info("Extracting %s to %s", allegato.nome_attachment, dest)
with open(dest, "wb") as fd:
fd.write(allegato.attachment)
else:
for index, allegato in selected:
self.print_allegato(index, allegato)
def main():
parser = argparse.ArgumentParser(description="Handle fattura elettronica files")
parser.add_argument("--verbose", "-v", action="store_true", help="verbose output")
parser.add_argument("--debug", action="store_true", help="debug output")
subparsers = parser.add_subparsers(help="actions", required=True)
subparsers.dest = "command"
ExportJSON.add_subparser(subparsers)
ExportYAML.add_subparser(subparsers)
ExportXML.add_subparser(subparsers)
ExportPython.add_subparser(subparsers)
Edit.add_subparser(subparsers)
Diff.add_subparser(subparsers)
Validate.add_subparser(subparsers)
RenderHTML.add_subparser(subparsers)
RenderPDF.add_subparser(subparsers)
UpdateCAPath.add_subparser(subparsers)
Allegati.add_subparser(subparsers)
args = parser.parse_args()
log_format = "%(levelname)s %(message)s"
level = logging.WARN
if args.debug:
level = logging.DEBUG
elif args.verbose:
level = logging.INFO
logging.basicConfig(level=level, stream=sys.stderr, format=log_format)
app = args.app(args)
res = app.run()
if isinstance(res, int):
sys.exit(res)
if __name__ == "__main__":
try:
main()
except Fail as e:
print(e, file=sys.stderr)
sys.exit(1)
except Exception:
log.exception("uncaught exception")
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/a38tool.md 0000644 0001777 0001777 00000013765 14373374076 014174 0 ustar 00valhalla valhalla # `a38tool`
General command line help:
```text
$ a38tool --help
usage: a38tool [-h] [--verbose] [--debug]
{json,xml,python,diff,validate,html,pdf,update_capath} ...
Handle fattura elettronica files
positional arguments:
{json,xml,python,diff,validate,html,pdf,update_capath}
actions
json output a fattura in JSON
xml output a fattura in XML
python output a fattura as Python code
diff show the difference between two fatture
validate validate the contents of a fattura
html render a Fattura as HTML using a .xslt stylesheet
pdf render a Fattura as PDF using a .xslt stylesheet
update_capath create/update an openssl CApath with CA certificates
that can be used to validate digital signatures
optional arguments:
-h, --help show this help message and exit
--verbose, -v verbose output
--debug debug output
```
### Difference between two fatture
```text
$ a38tool diff --help
usage: a38tool diff [-h] first second
positional arguments:
first first input file (.xml or .xml.p7m)
second second input file (.xml or .xml.p7m)
optional arguments:
-h, --help show this help message and exit
```
Example:
```text
$ a38tool diff doc/IT01234567890_FPR01.xml doc/IT01234567890_FPR02.xml
fattura_elettronica_header.dati_trasmissione.codice_destinatario: first: ABC1234, second: 0000000
fattura_elettronica_header.dati_trasmissione.pec_destinatario: first is not set
fattura_elettronica_header.cedente_prestatore.dati_anagrafici.regime_fiscale: first: RF19, second: RF01
fattura_elettronica_header.cessionario_committente.dati_anagrafici.anagrafica.denominazione: first: DITTA BETA, second: …
fattura_elettronica_body.0.dati_generali.dati_contratto: second is not set
fattura_elettronica_body.0.dati_beni_servizi.dettaglio_linee.0.descrizione: first: DESCRIZIONE DELLA FORNITURA, second: …
…
$ echo $?
1
```
### Validate a fattura
```text
$ a38tool validate --help
usage: a38tool validate [-h] file
positional arguments:
file input file (.xml or .xml.p7m)
optional arguments:
-h, --help show this help message and exit
```
Example:
```text
$ a38tool validate doc/IT01234567890_FPR01.xml
fattura_elettronica_body.0.dati_beni_servizi.unita_misura: field must be present when quantita is set
$ echo $?
1
```
### Convert a fattura to JSON
```text
$ a38tool json --help
usage: a38tool json [-h] [-o OUTPUT] [--indent INDENT] files [files ...]
positional arguments:
files input files (.xml or .xml.p7m)
optional arguments:
-h, --help show this help message and exit
-o OUTPUT, --output OUTPUT
output file (default: standard output)
--indent INDENT indentation space (default: 1, use 'no' for all in one
line)
```
Example:
```text
$ a38tool json doc/IT01234567890_FPR02.xml
{
"fattura_elettronica_header": {
"dati_trasmissione": {
"id_trasmittente": {
"id_paese": "IT",
"id_codice": "01234567890"
…
```
Use `--indent=no` to output a json per line, making it easy to separate reparse
a group of JSON fatture:
```text
$ a38tool json --indent=no doc/*.xml
{"fattura_elettronica_header": {"dati_tr…
{"fattura_elettronica_header": {"dati_tr…
{"fattura_elettronica_header": {"dati_tr…
…
```
### Extract XML from a `.p7m` signed fattura
```text
$ a38tool xml --help
usage: a38tool xml [-h] [-o OUTPUT] files [files ...]
positional arguments:
files input files (.xml or .xml.p7m)
optional arguments:
-h, --help show this help message and exit
-o OUTPUT, --output OUTPUT
output file (default: standard output)
```
### Generate Python code
You can convert a fattura to Python code: this is a quick way to start writing
a software that generates fatture similar to an existing one.
```text
$ a38tool python --help
usage: a38tool python [-h] [-o OUTPUT] [--namespace NAMESPACE] [--unformatted]
files [files ...]
positional arguments:
files input files (.xml or .xml.p7m)
optional arguments:
-h, --help show this help message and exit
-o OUTPUT, --output OUTPUT
output file (default: standard output)
--namespace NAMESPACE
namespace to use for the model classes (default: the
module fully qualified name)
--unformatted disable code formatting, outputting a single-line
statement
```
Example:
```text
$ a38tool python doc/IT01234567890_FPR02.xml
a38.fattura.FatturaPrivati12(
fattura_elettronica_header=a38.fattura.FatturaElettronicaHeader(
dati_trasmissione=a38.fattura.DatiTrasmissione(
id_trasmittente=a38.fattura.IdTrasmittente(
id_paese='IT', id_codice='01234567890'),
progressivo_invio='00001',
…
```
### Render to HTML or PDF
You can use a .xslt file to render a fattura to HTML or PDF.
```text
$ a38tool html --help
usage: a38tool html [-h] [-f] [-o OUTPUT] stylesheet files [files ...]
positional arguments:
stylesheet .xsl/.xslt stylesheet file to use for rendering
files input files (.xml or .xml.p7m)
optional arguments:
-h, --help show this help message and exit
-f, --force overwrite existing output files
-o OUTPUT, --output OUTPUT
output file; use {dirname} for the source file path,
{basename} for the source file name (default:
'{dirname}/{basename}{ext}.html'
```
Example:
```text
$ a38tool -v html -f doc/fatturaordinaria_v1.2.1.xsl doc/IT01234567890_FPR02.xml
INFO doc/IT01234567890_FPR02.xml: writing doc/IT01234567890_FPR02.xml.html
```
```text
$ a38tool -v pdf -f doc/fatturaordinaria_v1.2.1.xsl doc/IT01234567890_FPR02.xml
INFO doc/IT01234567890_FPR02.xml: writing doc/IT01234567890_FPR02.xml.pdf
```
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1704893258.3214443
a38-0.1.8/doc/ 0000755 0001777 0001777 00000000000 14547515512 013105 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/doc/.gitignore 0000644 0001777 0001777 00000000063 14373374076 015101 0 ustar 00valhalla valhalla /IT01234567890_FP*.xml
/*.pdf
/*.xls
/*.xsd
/*.xsl
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/doc/README.md 0000644 0001777 0001777 00000001466 14373374076 014400 0 ustar 00valhalla valhalla Run the `download-docs` script to populate this directory with official
documentation. Note that the URLS may change, and the script may need to be
updated from time to time.
Normativa:
Formato:
Assocons validator:
Agenzia delle Entrate validator:
# TODO
Get documentation from
(it covers Fattura Elettronica Semplificata)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1676539966.0
a38-0.1.8/document-a38 0000755 0001777 0001777 00000006304 14373374076 014505 0 ustar 00valhalla valhalla #!/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