pycurenergia 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycurenergia-0.1.0/.gitignore +15 -0
- pycurenergia-0.1.0/LICENSE +21 -0
- pycurenergia-0.1.0/PKG-INFO +78 -0
- pycurenergia-0.1.0/README.md +59 -0
- pycurenergia-0.1.0/pyproject.toml +43 -0
- pycurenergia-0.1.0/scripts/anonymize.py +226 -0
- pycurenergia-0.1.0/src/pycurenergia/__init__.py +25 -0
- pycurenergia-0.1.0/src/pycurenergia/_parsing.py +52 -0
- pycurenergia-0.1.0/src/pycurenergia/electricity.py +175 -0
- pycurenergia-0.1.0/src/pycurenergia/errors.py +24 -0
- pycurenergia-0.1.0/src/pycurenergia/gas.py +173 -0
- pycurenergia-0.1.0/src/pycurenergia/models.py +79 -0
- pycurenergia-0.1.0/src/pycurenergia/numbers.py +62 -0
- pycurenergia-0.1.0/src/pycurenergia/parser.py +40 -0
- pycurenergia-0.1.0/src/pycurenergia/py.typed +0 -0
- pycurenergia-0.1.0/src/pycurenergia/text.py +41 -0
- pycurenergia-0.1.0/src/pycurenergia/validation.py +51 -0
- pycurenergia-0.1.0/tests/conftest.py +20 -0
- pycurenergia-0.1.0/tests/fixtures/electricity_2026-10.txt +260 -0
- pycurenergia-0.1.0/tests/fixtures/gas_2026-09.txt +207 -0
- pycurenergia-0.1.0/tests/test_anonymize.py +158 -0
- pycurenergia-0.1.0/tests/test_electricity.py +221 -0
- pycurenergia-0.1.0/tests/test_gas.py +243 -0
- pycurenergia-0.1.0/tests/test_numbers.py +37 -0
- pycurenergia-0.1.0/tests/test_parser.py +61 -0
- pycurenergia-0.1.0/tests/test_private_invoices.py +103 -0
- pycurenergia-0.1.0/tests/test_text.py +89 -0
- pycurenergia-0.1.0/tests/test_validation.py +90 -0
- pycurenergia-0.1.0/uv.lock +313 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yuuues
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pycurenergia
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parser de facturas en PDF de CurEnergía (proyecto no oficial).
|
|
5
|
+
Project-URL: Source, https://github.com/yuuues/hacs-curenergia
|
|
6
|
+
Project-URL: Issues, https://github.com/yuuues/hacs-curenergia/issues
|
|
7
|
+
Author: yuuues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Requires-Dist: pypdf>=6.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pycurenergia
|
|
21
|
+
|
|
22
|
+
Librería Python que lee las facturas en PDF de CurEnergía (electricidad y gas)
|
|
23
|
+
y devuelve sus datos como objetos tipados. Todos los importes, kWh, m³ y
|
|
24
|
+
porcentajes son `decimal.Decimal`.
|
|
25
|
+
|
|
26
|
+
> Proyecto no oficial: no está afiliado, respaldado ni patrocinado por
|
|
27
|
+
> CurEnergía.
|
|
28
|
+
|
|
29
|
+
Licencia MIT.
|
|
30
|
+
|
|
31
|
+
## Uso
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pycurenergia import check_consistency, parse_invoice_pdf
|
|
35
|
+
|
|
36
|
+
with open("factura.pdf", "rb") as f:
|
|
37
|
+
invoice = parse_invoice_pdf(f.read())
|
|
38
|
+
|
|
39
|
+
print(invoice.supply, invoice.total)
|
|
40
|
+
print(check_consistency(invoice)) # [] si los importes cuadran
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `parse_invoice_pdf(data: bytes)` lee un PDF; `parse_invoice_text(text: str)`
|
|
44
|
+
parsea el texto ya extraído.
|
|
45
|
+
- Errores (todos derivan de `Exception`; ninguno incluye texto de la factura en
|
|
46
|
+
su mensaje):
|
|
47
|
+
- `InvoiceReadError`: el PDF no se puede leer (dañado, vacío, cifrado con
|
|
48
|
+
contraseña) o es demasiado grande (más de 10 MiB o de 20 páginas). Solo lo
|
|
49
|
+
lanza `parse_invoice_pdf`; el error original de pypdf queda en
|
|
50
|
+
`__cause__`.
|
|
51
|
+
- `UnsupportedInvoiceError`: el documento no es una factura de luz o de gas,
|
|
52
|
+
o es una factura rectificativa o de abono (no soportadas).
|
|
53
|
+
- `InvoiceParseError` (con el atributo `field`): falta algún dato obligatorio
|
|
54
|
+
o no tiene el formato esperado.
|
|
55
|
+
- `check_consistency(invoice)` devuelve una lista de `Problem`; vacía si todo
|
|
56
|
+
cuadra.
|
|
57
|
+
- `invoice.energy_price_kwh` es el precio de la energía en €/kWh, o `None` si
|
|
58
|
+
la factura no lo indica.
|
|
59
|
+
|
|
60
|
+
## Publicar
|
|
61
|
+
|
|
62
|
+
El workflow `.github/workflows/lib-release.yml` construye y publica en PyPI al
|
|
63
|
+
subir una etiqueta `pycurenergia-v*` (por ejemplo `pycurenergia-v0.1.0`),
|
|
64
|
+
usando *trusted publishing* (sin tokens).
|
|
65
|
+
|
|
66
|
+
Paso manual previo, una sola vez:
|
|
67
|
+
|
|
68
|
+
1. Crear la cuenta en [PyPI](https://pypi.org).
|
|
69
|
+
2. En PyPI, añadir un *pending trusted publisher* con estos datos:
|
|
70
|
+
- Proyecto: `pycurenergia`
|
|
71
|
+
- Owner: `yuuues`
|
|
72
|
+
- Repositorio: `hacs-curenergia`
|
|
73
|
+
- Workflow: `lib-release.yml`
|
|
74
|
+
- Entorno: `pypi`
|
|
75
|
+
3. Crear el entorno `pypi` en los ajustes del repositorio de GitHub y
|
|
76
|
+
configurar en él *required reviewers* (revisores obligatorios), de modo que
|
|
77
|
+
subir una etiqueta por sí solo no publique nada sin una aprobación manual.
|
|
78
|
+
4. Etiquetar y subir: `git tag pycurenergia-v0.1.0 && git push origin pycurenergia-v0.1.0`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# pycurenergia
|
|
2
|
+
|
|
3
|
+
Librería Python que lee las facturas en PDF de CurEnergía (electricidad y gas)
|
|
4
|
+
y devuelve sus datos como objetos tipados. Todos los importes, kWh, m³ y
|
|
5
|
+
porcentajes son `decimal.Decimal`.
|
|
6
|
+
|
|
7
|
+
> Proyecto no oficial: no está afiliado, respaldado ni patrocinado por
|
|
8
|
+
> CurEnergía.
|
|
9
|
+
|
|
10
|
+
Licencia MIT.
|
|
11
|
+
|
|
12
|
+
## Uso
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from pycurenergia import check_consistency, parse_invoice_pdf
|
|
16
|
+
|
|
17
|
+
with open("factura.pdf", "rb") as f:
|
|
18
|
+
invoice = parse_invoice_pdf(f.read())
|
|
19
|
+
|
|
20
|
+
print(invoice.supply, invoice.total)
|
|
21
|
+
print(check_consistency(invoice)) # [] si los importes cuadran
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `parse_invoice_pdf(data: bytes)` lee un PDF; `parse_invoice_text(text: str)`
|
|
25
|
+
parsea el texto ya extraído.
|
|
26
|
+
- Errores (todos derivan de `Exception`; ninguno incluye texto de la factura en
|
|
27
|
+
su mensaje):
|
|
28
|
+
- `InvoiceReadError`: el PDF no se puede leer (dañado, vacío, cifrado con
|
|
29
|
+
contraseña) o es demasiado grande (más de 10 MiB o de 20 páginas). Solo lo
|
|
30
|
+
lanza `parse_invoice_pdf`; el error original de pypdf queda en
|
|
31
|
+
`__cause__`.
|
|
32
|
+
- `UnsupportedInvoiceError`: el documento no es una factura de luz o de gas,
|
|
33
|
+
o es una factura rectificativa o de abono (no soportadas).
|
|
34
|
+
- `InvoiceParseError` (con el atributo `field`): falta algún dato obligatorio
|
|
35
|
+
o no tiene el formato esperado.
|
|
36
|
+
- `check_consistency(invoice)` devuelve una lista de `Problem`; vacía si todo
|
|
37
|
+
cuadra.
|
|
38
|
+
- `invoice.energy_price_kwh` es el precio de la energía en €/kWh, o `None` si
|
|
39
|
+
la factura no lo indica.
|
|
40
|
+
|
|
41
|
+
## Publicar
|
|
42
|
+
|
|
43
|
+
El workflow `.github/workflows/lib-release.yml` construye y publica en PyPI al
|
|
44
|
+
subir una etiqueta `pycurenergia-v*` (por ejemplo `pycurenergia-v0.1.0`),
|
|
45
|
+
usando *trusted publishing* (sin tokens).
|
|
46
|
+
|
|
47
|
+
Paso manual previo, una sola vez:
|
|
48
|
+
|
|
49
|
+
1. Crear la cuenta en [PyPI](https://pypi.org).
|
|
50
|
+
2. En PyPI, añadir un *pending trusted publisher* con estos datos:
|
|
51
|
+
- Proyecto: `pycurenergia`
|
|
52
|
+
- Owner: `yuuues`
|
|
53
|
+
- Repositorio: `hacs-curenergia`
|
|
54
|
+
- Workflow: `lib-release.yml`
|
|
55
|
+
- Entorno: `pypi`
|
|
56
|
+
3. Crear el entorno `pypi` en los ajustes del repositorio de GitHub y
|
|
57
|
+
configurar en él *required reviewers* (revisores obligatorios), de modo que
|
|
58
|
+
subir una etiqueta por sí solo no publique nada sin una aprobación manual.
|
|
59
|
+
4. Etiquetar y subir: `git tag pycurenergia-v0.1.0 && git push origin pycurenergia-v0.1.0`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pycurenergia"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Parser de facturas en PDF de CurEnergía (proyecto no oficial)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "yuuues" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.13"
|
|
14
|
+
dependencies = ["pypdf>=6.0"]
|
|
15
|
+
# Sin clasificador «License :: …»: PEP 639 lo sustituye por ``license`` (MIT) y
|
|
16
|
+
# PyPI rechaza combinarlo con una expresión de licencia.
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Source = "https://github.com/yuuues/hacs-curenergia"
|
|
28
|
+
Issues = "https://github.com/yuuues/hacs-curenergia/issues"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8", "ruff>=0.6", "reportlab>=4"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/pycurenergia"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py313"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Anonimiza el texto de una factura de CurEnergía para usarlo como fixture.
|
|
2
|
+
|
|
3
|
+
Uso:
|
|
4
|
+
anonymize.py PDF --out TXT [--secrets SECRETS]
|
|
5
|
+
anonymize.py --check TXT... [--secrets SECRETS]
|
|
6
|
+
|
|
7
|
+
Los valores reales y sus sustitutos se leen de ``private/secrets.toml``
|
|
8
|
+
(excluido de git)::
|
|
9
|
+
|
|
10
|
+
[replace]
|
|
11
|
+
"valor real" = "valor falso"
|
|
12
|
+
|
|
13
|
+
[drop]
|
|
14
|
+
patterns = ["regex que elimina la línea entera"]
|
|
15
|
+
|
|
16
|
+
El script no se empaqueta con la librería.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import tomllib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from pycurenergia.text import extract_text
|
|
26
|
+
|
|
27
|
+
DEFAULT_SECRETS = Path(__file__).parents[2] / "private" / "secrets.toml"
|
|
28
|
+
FAKE_NIF = "00000000T"
|
|
29
|
+
|
|
30
|
+
_NIF_RE = re.compile(r"\b\d{8}[A-Z]\b")
|
|
31
|
+
_LONG_DIGITS_RE = re.compile(r"\d{20,}")
|
|
32
|
+
_WORD_RE = re.compile(r"[^\W\d_]{4,}")
|
|
33
|
+
_SYMBOL_CHARS = frozenset("\t[]{}<>@_=^~|\\")
|
|
34
|
+
_ALLOWED_CHARS = frozenset(
|
|
35
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
36
|
+
"áéíóúüñÁÉÍÓÚÜÑçǪº"
|
|
37
|
+
".,:;()/%€-'\"*+&!?¿¡"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_noise(line: str) -> bool:
|
|
42
|
+
"""Indica si la línea es un código de barras o texto de una fuente de glifos."""
|
|
43
|
+
if _LONG_DIGITS_RE.search(line):
|
|
44
|
+
return True
|
|
45
|
+
visible = [c for c in line if not c.isspace()]
|
|
46
|
+
outside = sum(1 for c in visible if c not in _ALLOWED_CHARS)
|
|
47
|
+
if outside * 2 > len(visible):
|
|
48
|
+
return True
|
|
49
|
+
return not _WORD_RE.search(line) and any(c in _SYMBOL_CHARS for c in line)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def drop_noise_lines(text: str, patterns: list[str] | None = None) -> str:
|
|
53
|
+
"""Elimina las líneas de códigos de barras y glifos sin sentido.
|
|
54
|
+
|
|
55
|
+
``patterns`` son expresiones regulares adicionales: se elimina toda línea
|
|
56
|
+
en la que alguna de ellas encuentre coincidencia. Cada página (separada por
|
|
57
|
+
``"\\f"``) se trata por separado para no perder los saltos de página, que
|
|
58
|
+
pypdf deja a veces en mitad de una línea.
|
|
59
|
+
"""
|
|
60
|
+
extra = [re.compile(p) for p in patterns or []]
|
|
61
|
+
|
|
62
|
+
def keep(line: str) -> bool:
|
|
63
|
+
return not _is_noise(line) and not any(p.search(line) for p in extra)
|
|
64
|
+
|
|
65
|
+
pages = text.split("\f")
|
|
66
|
+
return "\f".join("\n".join(filter(keep, page.split("\n"))) for page in pages)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_replacements(text: str, mapping: dict[str, str]) -> str:
|
|
70
|
+
"""Sustituye cada valor real por su valor falso.
|
|
71
|
+
|
|
72
|
+
Las claves se prueban de la más larga a la más corta, en una sola pasada,
|
|
73
|
+
así que un valor falso nunca se vuelve a sustituir.
|
|
74
|
+
"""
|
|
75
|
+
if not mapping:
|
|
76
|
+
return text
|
|
77
|
+
keys = sorted(mapping, key=len, reverse=True)
|
|
78
|
+
pattern = re.compile("|".join(re.escape(k) for k in keys))
|
|
79
|
+
return pattern.sub(lambda m: mapping[m.group(0)], text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mask_nif(text: str) -> str:
|
|
83
|
+
"""Sustituye cualquier NIF (8 dígitos y una letra) por ``00000000T``."""
|
|
84
|
+
return _NIF_RE.sub(FAKE_NIF, text)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def find_leaks(text: str, mapping: dict[str, str]) -> list[tuple[int, str]]:
|
|
88
|
+
"""Busca valores reales que hayan quedado en el texto.
|
|
89
|
+
|
|
90
|
+
Devuelve ``(nº de línea, valor real)``, ordenado por línea. Cada valor se
|
|
91
|
+
busca sin distinguir mayúsculas, tal cual y sin espacios en cada línea, y
|
|
92
|
+
además en todo el texto sin ningún espacio en blanco (saltos de línea y de
|
|
93
|
+
página, tabuladores y espacios duros incluidos), para detectar valores
|
|
94
|
+
partidos entre líneas; en ese caso la línea es la de su comienzo. También se
|
|
95
|
+
informa de cualquier NIF distinto de ``00000000T``.
|
|
96
|
+
"""
|
|
97
|
+
needles = []
|
|
98
|
+
for real in mapping:
|
|
99
|
+
forms = {real.casefold(), real.replace(" ", "").casefold()}
|
|
100
|
+
needles.append((real, {f for f in forms if f}))
|
|
101
|
+
leaks = []
|
|
102
|
+
reported = set()
|
|
103
|
+
lines = text.split("\n")
|
|
104
|
+
for number, line in enumerate(lines, start=1):
|
|
105
|
+
folded = line.casefold()
|
|
106
|
+
for real, forms in needles:
|
|
107
|
+
if any(f in folded for f in forms):
|
|
108
|
+
leaks.append((number, real))
|
|
109
|
+
reported.add(real)
|
|
110
|
+
for nif in _NIF_RE.findall(line):
|
|
111
|
+
if nif != FAKE_NIF:
|
|
112
|
+
leaks.append((number, nif))
|
|
113
|
+
|
|
114
|
+
# Texto sin espacios en blanco, con la línea de origen de cada carácter.
|
|
115
|
+
chars = [
|
|
116
|
+
(folded, number)
|
|
117
|
+
for number, line in enumerate(lines, start=1)
|
|
118
|
+
for c in line
|
|
119
|
+
if not c.isspace()
|
|
120
|
+
for folded in c.casefold()
|
|
121
|
+
]
|
|
122
|
+
squeezed = "".join(c for c, _ in chars)
|
|
123
|
+
for real in mapping:
|
|
124
|
+
needle = "".join(c for c in real if not c.isspace()).casefold()
|
|
125
|
+
if real in reported or not needle:
|
|
126
|
+
continue
|
|
127
|
+
index = squeezed.find(needle)
|
|
128
|
+
if index >= 0:
|
|
129
|
+
leaks.append((chars[index][1], real))
|
|
130
|
+
leaks.sort(key=lambda leak: leak[0])
|
|
131
|
+
return leaks
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_secrets(path: Path) -> tuple[dict[str, str], list[str]]:
|
|
135
|
+
"""Lee ``[replace]`` y ``[drop] patterns`` del fichero de secretos.
|
|
136
|
+
|
|
137
|
+
Lanza ``ValueError`` si el formato no es válido: ``[replace]`` debe ser una
|
|
138
|
+
tabla de cadenas no vacías y ``[drop] patterns`` una lista de cadenas no
|
|
139
|
+
vacías. Los mensajes nunca incluyen los valores del fichero.
|
|
140
|
+
"""
|
|
141
|
+
with path.open("rb") as fh:
|
|
142
|
+
data = tomllib.load(fh)
|
|
143
|
+
error = f"{path.name} no válido: "
|
|
144
|
+
replace = data.get("replace", {})
|
|
145
|
+
if not isinstance(replace, dict):
|
|
146
|
+
raise ValueError(error + "[replace] debe ser una tabla")
|
|
147
|
+
for index, (real, fake) in enumerate(replace.items(), start=1):
|
|
148
|
+
if not real.strip() or not isinstance(fake, str) or not fake.strip():
|
|
149
|
+
raise ValueError(
|
|
150
|
+
error + f"la clave nº {index} de [replace] o su valor no es "
|
|
151
|
+
"una cadena no vacía"
|
|
152
|
+
)
|
|
153
|
+
drop = data.get("drop", {})
|
|
154
|
+
patterns = drop.get("patterns", []) if isinstance(drop, dict) else None
|
|
155
|
+
if not isinstance(patterns, list) or not all(
|
|
156
|
+
isinstance(p, str) and p for p in patterns
|
|
157
|
+
):
|
|
158
|
+
raise ValueError(error + "[drop] patterns debe ser una lista de cadenas")
|
|
159
|
+
return dict(replace), list(patterns)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def anonymize(text: str, mapping: dict[str, str], patterns: list[str]) -> str:
|
|
163
|
+
"""Aplica en orden: limpieza de ruido, sustituciones y máscara de NIF."""
|
|
164
|
+
return mask_nif(apply_replacements(drop_noise_lines(text, patterns), mapping))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _report(name: str, leaks: list[tuple[int, str]], mapping: dict[str, str]) -> None:
|
|
168
|
+
"""Informa de cada fuga sin mostrar nunca el valor real.
|
|
169
|
+
|
|
170
|
+
Un valor de ``[replace]`` se identifica por su posición en el fichero de
|
|
171
|
+
secretos; un NIF, solo por su longitud.
|
|
172
|
+
"""
|
|
173
|
+
keys = list(mapping)
|
|
174
|
+
for number, value in leaks:
|
|
175
|
+
if value in mapping:
|
|
176
|
+
what = f"clave nº {keys.index(value) + 1} de [replace] sin anonimizar"
|
|
177
|
+
else:
|
|
178
|
+
what = f"NIF sin enmascarar ({len(value)} caracteres)"
|
|
179
|
+
print(f"{name}:{number}: {what}", file=sys.stderr)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> int:
|
|
183
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"inputs", nargs="+", type=Path, help="PDF a anonimizar, o TXT con --check"
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument("--out", type=Path, help="fichero de texto de salida")
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--check", action="store_true", help="solo comprueba fixtures ya generadas"
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument("--secrets", type=Path, default=DEFAULT_SECRETS)
|
|
192
|
+
args = parser.parse_args(argv)
|
|
193
|
+
# La consola de Windows puede no ser UTF-8; mejor escapar que fallar al informar.
|
|
194
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
195
|
+
sys.stderr.reconfigure(errors="backslashreplace")
|
|
196
|
+
if not args.secrets.is_file():
|
|
197
|
+
parser.error(f"no existe el fichero de secretos {args.secrets}")
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
mapping, patterns = load_secrets(args.secrets)
|
|
201
|
+
except ValueError as exc:
|
|
202
|
+
parser.error(str(exc))
|
|
203
|
+
|
|
204
|
+
if args.check:
|
|
205
|
+
failed = False
|
|
206
|
+
for path in args.inputs:
|
|
207
|
+
leaks = find_leaks(path.read_text(encoding="utf-8"), mapping)
|
|
208
|
+
_report(str(path), leaks, mapping)
|
|
209
|
+
failed = failed or bool(leaks)
|
|
210
|
+
return 1 if failed else 0
|
|
211
|
+
|
|
212
|
+
if len(args.inputs) != 1 or args.out is None:
|
|
213
|
+
parser.error("hay que indicar un único PDF y --out")
|
|
214
|
+
text = anonymize(extract_text(args.inputs[0].read_bytes()), mapping, patterns)
|
|
215
|
+
leaks = find_leaks(text, mapping)
|
|
216
|
+
if leaks:
|
|
217
|
+
_report(str(args.inputs[0]), leaks, mapping)
|
|
218
|
+
return 1
|
|
219
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
with args.out.open("w", encoding="utf-8", newline="\n") as fh:
|
|
221
|
+
fh.write(text)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Lector de facturas en PDF de CurEnergía."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .errors import InvoiceParseError, InvoiceReadError, UnsupportedInvoiceError
|
|
6
|
+
from .models import Concept, ConceptKey, Invoice, SupplyType
|
|
7
|
+
from .parser import parse_invoice_pdf, parse_invoice_text
|
|
8
|
+
from .validation import Problem, check_consistency
|
|
9
|
+
|
|
10
|
+
__version__ = version("pycurenergia")
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Concept",
|
|
14
|
+
"ConceptKey",
|
|
15
|
+
"Invoice",
|
|
16
|
+
"InvoiceParseError",
|
|
17
|
+
"InvoiceReadError",
|
|
18
|
+
"Problem",
|
|
19
|
+
"SupplyType",
|
|
20
|
+
"UnsupportedInvoiceError",
|
|
21
|
+
"__version__",
|
|
22
|
+
"check_consistency",
|
|
23
|
+
"parse_invoice_pdf",
|
|
24
|
+
"parse_invoice_text",
|
|
25
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Utilidades comunes a los lectores de facturas."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from pycurenergia.errors import InvoiceParseError
|
|
8
|
+
from pycurenergia.numbers import parse_decimal
|
|
9
|
+
|
|
10
|
+
NUM = r"-?\d[\d.]*(?:,\d+)?"
|
|
11
|
+
"""Patrón de un número en formato español (se interpreta con ``parse_decimal``)."""
|
|
12
|
+
|
|
13
|
+
BOL = r"(?:^|(?<=\f))"
|
|
14
|
+
"""Inicio de línea (con ``re.MULTILINE``) o justo tras un ``\\f``.
|
|
15
|
+
|
|
16
|
+
Las páginas se unen con ``\\f``, que puede quedar en mitad de una línea.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
CUPS = r"ES(?:[ \t]?\d){16}[ \t]?[A-Z]{2}(?:[ \t]?\d[A-Z])?\b"
|
|
20
|
+
"""CUPS: ``ES``, 16 dígitos, 2 letras de control y sufijo opcional (dígito y
|
|
21
|
+
letra), con espacios o tabuladores sueltos entre grupos."""
|
|
22
|
+
|
|
23
|
+
LINE_BREAK_RE = re.compile(r"[\n\f]")
|
|
24
|
+
"""Separa líneas tanto por ``\\n`` como por ``\\f`` (salto de página)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compact(value: str) -> str:
|
|
28
|
+
"""Quita todo el espacio en blanco de ``value`` (p. ej. el CUPS)."""
|
|
29
|
+
return re.sub(r"\s+", "", value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def search(pattern: re.Pattern[str], text: str, field: str) -> re.Match[str]:
|
|
33
|
+
"""Busca ``pattern`` o lanza ``InvoiceParseError`` indicando ``field``."""
|
|
34
|
+
match = pattern.search(text)
|
|
35
|
+
if match is None:
|
|
36
|
+
raise InvoiceParseError(field)
|
|
37
|
+
return match
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def convert[T](parse: Callable[[str], T], value: str, field: str) -> T:
|
|
41
|
+
"""Aplica ``parse`` a ``value``; un ``ValueError`` pasa a ``InvoiceParseError``."""
|
|
42
|
+
try:
|
|
43
|
+
return parse(value)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise InvoiceParseError(
|
|
46
|
+
field, f"Valor no válido en {field!r}: {value!r}"
|
|
47
|
+
) from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_decimal(value: str, field: str) -> Decimal:
|
|
51
|
+
"""Convierte un número español a ``Decimal`` o lanza ``InvoiceParseError``."""
|
|
52
|
+
return convert(parse_decimal, value, field)
|