csf-scraper-python 0.1.1__py3-none-any.whl
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.
- csf_scraper/__init__.py +119 -0
- csf_scraper/data_extractor.py +74 -0
- csf_scraper/date_parser.py +26 -0
- csf_scraper/exceptions/__init__.py +17 -0
- csf_scraper/exceptions/image_reader.py +106 -0
- csf_scraper/exceptions/pdf_reader.py +33 -0
- csf_scraper/exceptions/value_objects.py +11 -0
- csf_scraper/image_readers/__init__.py +52 -0
- csf_scraper/image_readers/adapters/__init__.py +18 -0
- csf_scraper/image_readers/adapters/llms/__init__.py +33 -0
- csf_scraper/image_readers/adapters/llms/gemini_image_reader.py +157 -0
- csf_scraper/image_readers/adapters/llms/prompts/__init__.py +8 -0
- csf_scraper/image_readers/adapters/llms/prompts/llm_image_reader_prompt.py +17 -0
- csf_scraper/image_readers/csf_image_extractor.py +29 -0
- csf_scraper/image_readers/ports/__init__.py +5 -0
- csf_scraper/image_readers/ports/image_reader_port.py +37 -0
- csf_scraper/image_readers/schemas/__init__.py +5 -0
- csf_scraper/image_readers/schemas/rfc_cif_schema.py +42 -0
- csf_scraper/image_readers/services/__init__.py +18 -0
- csf_scraper/image_readers/services/llm_image_reader_service.py +328 -0
- csf_scraper/pdf_reader/__init__.py +7 -0
- csf_scraper/pdf_reader/csf_pdf_extractor.py +33 -0
- csf_scraper/pdf_reader/pdf_csf_text_parser.py +54 -0
- csf_scraper/pdf_reader/pdf_to_text.py +36 -0
- csf_scraper/persona.py +64 -0
- csf_scraper/persona_fisica.py +25 -0
- csf_scraper/persona_moral.py +21 -0
- csf_scraper/ports/__init__.py +5 -0
- csf_scraper/ports/rfc_cif_extractor_port.py +40 -0
- csf_scraper/py.typed +0 -0
- csf_scraper/regimen.py +62 -0
- csf_scraper/rfc_cif_data.py +15 -0
- csf_scraper/scraper.py +117 -0
- csf_scraper/value_objects/__init__.py +7 -0
- csf_scraper/value_objects/csf_image.py +68 -0
- csf_scraper/value_objects/id_cif.py +48 -0
- csf_scraper/value_objects/rfc.py +56 -0
- csf_scraper_python-0.1.1.dist-info/METADATA +390 -0
- csf_scraper_python-0.1.1.dist-info/RECORD +42 -0
- csf_scraper_python-0.1.1.dist-info/WHEEL +4 -0
- csf_scraper_python-0.1.1.dist-info/licenses/LICENSE +21 -0
- csf_scraper_python-0.1.1.dist-info/licenses/NOTICE.md +16 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Modelo de persona física."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from csf_scraper.date_parser import DateField
|
|
6
|
+
from csf_scraper.persona import Persona
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PersonaFisica(Persona):
|
|
10
|
+
"""Datos fiscales de una persona física."""
|
|
11
|
+
|
|
12
|
+
curp: str = ""
|
|
13
|
+
nombre: str = ""
|
|
14
|
+
apellido_paterno: str = ""
|
|
15
|
+
apellido_materno: str = ""
|
|
16
|
+
fecha_nacimiento: DateField = None
|
|
17
|
+
|
|
18
|
+
field_titles: ClassVar[dict[str, str]] = {
|
|
19
|
+
"curp": "CURP:",
|
|
20
|
+
"nombre": "Nombre:",
|
|
21
|
+
"apellido_paterno": "Apellido Paterno:",
|
|
22
|
+
"apellido_materno": "Apellido Materno:",
|
|
23
|
+
"fecha_nacimiento": "Fecha Nacimiento:",
|
|
24
|
+
**Persona.field_titles,
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Modelo de persona moral."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from csf_scraper.date_parser import DateField
|
|
6
|
+
from csf_scraper.persona import Persona
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PersonaMoral(Persona):
|
|
10
|
+
"""Datos fiscales de una persona moral."""
|
|
11
|
+
|
|
12
|
+
razon_social: str = ""
|
|
13
|
+
regimen_de_capital: str = ""
|
|
14
|
+
fecha_constitucion: DateField = None
|
|
15
|
+
|
|
16
|
+
field_titles: ClassVar[dict[str, str]] = {
|
|
17
|
+
"razon_social": "Denominación o Razón Social:",
|
|
18
|
+
"regimen_de_capital": "Régimen de capital:",
|
|
19
|
+
"fecha_constitucion": "Fecha de constitución:",
|
|
20
|
+
**Persona.field_titles,
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Puerto que unifica la extracción de RFC e idCIF de una CSF desde una ruta."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RfcCifExtractorPort(ABC):
|
|
9
|
+
"""Extrae el :class:`RfcCifData` de una Constancia de Situación Fiscal.
|
|
10
|
+
|
|
11
|
+
Contrato público de los adaptadores que leen una CSF *desde una ruta*
|
|
12
|
+
(PDF, imagen o cualquier formato futuro). ``Scraper`` solo depende de este
|
|
13
|
+
puerto, así que todos los adaptadores son intercambiables entre sí.
|
|
14
|
+
|
|
15
|
+
Reglas del contrato:
|
|
16
|
+
|
|
17
|
+
- La ruta se recibe en :meth:`extract`, **no** en el constructor: los
|
|
18
|
+
adaptadores son sin estado y reutilizables entre archivos.
|
|
19
|
+
- El constructor se reserva a la configuración del motor (credenciales,
|
|
20
|
+
modelo, binarios o dependencias inyectadas).
|
|
21
|
+
- El adaptador valida y devuelve :class:`RfcCifData` con los value objects
|
|
22
|
+
ya construidos (``rfc: Rfc``, ``id_cif: IdCif``); nunca cadenas crudas.
|
|
23
|
+
- Los adaptadores de la librería reportan sus fallos con subclases de
|
|
24
|
+
:class:`~csf_scraper.exceptions.CsfScraperException`. Las dependencias
|
|
25
|
+
inyectadas (por ejemplo, un ``ImageReaderPort`` externo) pueden propagar
|
|
26
|
+
sus propias excepciones.
|
|
27
|
+
|
|
28
|
+
Implementaciones: :class:`~csf_scraper.pdf_reader.CsfPdfExtractor` (texto
|
|
29
|
+
del PDF) y :class:`~csf_scraper.image_readers.CsfImageExtractor` (cualquier
|
|
30
|
+
:class:`~csf_scraper.image_readers.ports.ImageReaderPort`).
|
|
31
|
+
|
|
32
|
+
El contrato es **asíncrono**: ``extract`` es una corrutina para que las
|
|
33
|
+
implementaciones puedan delegar E/S (procesos, disco, red) sin bloquear el
|
|
34
|
+
bucle de eventos. ``Scraper`` la consume con ``await``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def extract(self, path: str) -> RfcCifData:
|
|
39
|
+
"""Devuelve el RFC y el idCIF leídos del archivo en ``path``."""
|
|
40
|
+
...
|
csf_scraper/py.typed
ADDED
|
File without changes
|
csf_scraper/regimen.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Modelo de un régimen fiscal."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, computed_field
|
|
6
|
+
|
|
7
|
+
from csf_scraper.date_parser import DateField
|
|
8
|
+
|
|
9
|
+
_REGIMEN_CATALOG: dict[str, str] = {
|
|
10
|
+
"601": "General de Ley Personas Morales",
|
|
11
|
+
"603": "Personas Morales con Fines no Lucrativos",
|
|
12
|
+
"605": "Sueldos y Salarios e Ingresos Asimilados a Salarios",
|
|
13
|
+
"606": "Arrendamiento",
|
|
14
|
+
"607": "Enajenación o Adquisición de Bienes",
|
|
15
|
+
"608": "Demás ingresos",
|
|
16
|
+
"610": "Residentes en el Extranjero sin Establecimiento Permanente en México",
|
|
17
|
+
"611": "Ingresos por Dividendos (socios y accionistas)",
|
|
18
|
+
"612": "Personas Físicas con Actividades Empresariales y Profesionales",
|
|
19
|
+
"614": "Ingresos por intereses",
|
|
20
|
+
"615": "ingresos por obtención de premios",
|
|
21
|
+
"616": "Sin obligaciones fiscales",
|
|
22
|
+
"620": "Sociedades Cooperativas de Producción que optan por diferir sus ingresos",
|
|
23
|
+
"621": "Incorporación Fiscal",
|
|
24
|
+
"622": "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras",
|
|
25
|
+
"623": "Opcional para Grupos de Sociedades",
|
|
26
|
+
"624": "Coordinados",
|
|
27
|
+
"625": "Actividades Empresariales con ingresos a través de Plataformas Tecnológicas",
|
|
28
|
+
"626": "Simplificado de Confianza",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_REGIMEN_BY_TEXT: dict[str, str] = {
|
|
32
|
+
text.upper(): regimen_id for regimen_id, text in _REGIMEN_CATALOG.items()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_PREFIX = re.compile(r"^Régimen (?:de (?:las |los )?)?")
|
|
36
|
+
_SUFFIX_PM = re.compile(r" PM$")
|
|
37
|
+
_SUFFIX_DOT = re.compile(r"\.$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_text(regimen: str) -> str:
|
|
41
|
+
text = _PREFIX.sub("", regimen)
|
|
42
|
+
text = _SUFFIX_PM.sub("", text)
|
|
43
|
+
return _SUFFIX_DOT.sub("", text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _search_regimen_id(regimen: str) -> str:
|
|
47
|
+
return _REGIMEN_BY_TEXT.get(_normalize_text(regimen).upper(), "")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Regimen(BaseModel):
|
|
51
|
+
"""Un régimen fiscal con su id derivado del texto y su fecha de alta."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
54
|
+
|
|
55
|
+
regimen: str = ""
|
|
56
|
+
fecha_alta: DateField = None
|
|
57
|
+
|
|
58
|
+
@computed_field # type: ignore[prop-decorator]
|
|
59
|
+
@property
|
|
60
|
+
def regimen_id(self) -> str:
|
|
61
|
+
"""Id del catálogo que corresponde al texto de :attr:`regimen`."""
|
|
62
|
+
return _search_regimen_id(self.regimen)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""DTO con el RFC y el idCIF extraídos de una CSF (PDF o imagen)."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from csf_scraper.value_objects.id_cif import IdCif
|
|
6
|
+
from csf_scraper.value_objects.rfc import Rfc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RfcCifData(BaseModel):
|
|
10
|
+
"""Par validado de RFC e idCIF obtenido por cualquier extractor."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
13
|
+
|
|
14
|
+
rfc: Rfc
|
|
15
|
+
id_cif: IdCif
|
csf_scraper/scraper.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Cliente para consultar los datos fiscales en el validador del SAT."""
|
|
2
|
+
|
|
3
|
+
import ssl
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import ClassVar, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from csf_scraper.data_extractor import DataExtractor
|
|
10
|
+
from csf_scraper.exceptions import CifDownloadException
|
|
11
|
+
from csf_scraper.exceptions.image_reader import ImageExtractorNotConfiguredException
|
|
12
|
+
from csf_scraper.pdf_reader import CsfPdfExtractor
|
|
13
|
+
from csf_scraper.persona_fisica import PersonaFisica
|
|
14
|
+
from csf_scraper.persona_moral import PersonaMoral
|
|
15
|
+
from csf_scraper.ports import RfcCifExtractorPort
|
|
16
|
+
from csf_scraper.value_objects.id_cif import IdCif
|
|
17
|
+
from csf_scraper.value_objects.rfc import Rfc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _create_ssl_context() -> ssl.SSLContext:
|
|
21
|
+
context = ssl.create_default_context()
|
|
22
|
+
context.set_ciphers("DEFAULT@SECLEVEL=1")
|
|
23
|
+
return context
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Scraper:
|
|
27
|
+
"""Obtiene una :class:`PersonaMoral` o :class:`PersonaFisica` desde el sitio del SAT.
|
|
28
|
+
|
|
29
|
+
Todo el flujo es asíncrono: la descarga usa un
|
|
30
|
+
:class:`httpx.AsyncClient` y la extracción de datos se espera con
|
|
31
|
+
``await``. El cliente se puede cerrar con :meth:`aclose` o usando el
|
|
32
|
+
scraper como *async context manager*::
|
|
33
|
+
|
|
34
|
+
async with Scraper.create() as scraper:
|
|
35
|
+
person = await scraper.obtain_from_rfc_and_cif(rfc, id_cif)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
url: ClassVar[str] = (
|
|
39
|
+
"https://siat.sat.gob.mx/app/qr/faces/pages/mobile/validadorqr.jsf?D1=10&D2=1&D3=%s_%s"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
client: httpx.AsyncClient,
|
|
45
|
+
*,
|
|
46
|
+
pdf_extractor: RfcCifExtractorPort | None = None,
|
|
47
|
+
image_extractor: RfcCifExtractorPort | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.client = client
|
|
50
|
+
#: Extractor de CSF en PDF; por omisión usa ``pdftotext``.
|
|
51
|
+
self.pdf_extractor: RfcCifExtractorPort = pdf_extractor or CsfPdfExtractor()
|
|
52
|
+
#: Extractor de CSF escaneada (LLM/OCR); opcional.
|
|
53
|
+
self.image_extractor = image_extractor
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(
|
|
57
|
+
cls,
|
|
58
|
+
*,
|
|
59
|
+
pdf_extractor: RfcCifExtractorPort | None = None,
|
|
60
|
+
image_extractor: RfcCifExtractorPort | None = None,
|
|
61
|
+
) -> Self:
|
|
62
|
+
"""Crea un cliente HTTP asíncrono listo para usar contra el SAT."""
|
|
63
|
+
return cls(
|
|
64
|
+
httpx.AsyncClient(follow_redirects=True, timeout=None, verify=_create_ssl_context()),
|
|
65
|
+
pdf_extractor=pdf_extractor,
|
|
66
|
+
image_extractor=image_extractor,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> Self:
|
|
70
|
+
"""Permite usar el scraper en un bloque ``async with``."""
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc_value: BaseException | None,
|
|
77
|
+
traceback: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Cierra el cliente HTTP al salir del bloque ``async with``."""
|
|
80
|
+
await self.aclose()
|
|
81
|
+
|
|
82
|
+
async def aclose(self) -> None:
|
|
83
|
+
"""Cierra el cliente HTTP subyacente."""
|
|
84
|
+
await self.client.aclose()
|
|
85
|
+
|
|
86
|
+
async def obtain_from_rfc_and_cif(self, rfc: str, id_cif: str) -> PersonaMoral | PersonaFisica:
|
|
87
|
+
"""Obtiene la persona a partir de su RFC y su idCIF."""
|
|
88
|
+
parsed_rfc = Rfc(rfc)
|
|
89
|
+
parsed_cif = IdCif(id_cif)
|
|
90
|
+
|
|
91
|
+
uri = type(self).url % (parsed_cif, parsed_rfc)
|
|
92
|
+
html = await self._obtain_html(uri)
|
|
93
|
+
person = DataExtractor(html).extract(is_fisica=parsed_rfc.is_fisica())
|
|
94
|
+
|
|
95
|
+
person.rfc = parsed_rfc
|
|
96
|
+
person.id_cif = parsed_cif
|
|
97
|
+
return person
|
|
98
|
+
|
|
99
|
+
async def obtain_from_pdf_path(self, path: str) -> PersonaMoral | PersonaFisica:
|
|
100
|
+
"""Obtiene la persona leyendo su RFC e idCIF de una CSF en PDF."""
|
|
101
|
+
data = await self.pdf_extractor.extract(path)
|
|
102
|
+
return await self.obtain_from_rfc_and_cif(data.rfc, data.id_cif)
|
|
103
|
+
|
|
104
|
+
async def obtain_from_image_path(self, path: str) -> PersonaMoral | PersonaFisica:
|
|
105
|
+
"""Obtiene la persona leyendo su RFC e idCIF de una CSF escaneada (JPEG/PNG)."""
|
|
106
|
+
if self.image_extractor is None:
|
|
107
|
+
raise ImageExtractorNotConfiguredException()
|
|
108
|
+
data = await self.image_extractor.extract(path)
|
|
109
|
+
return await self.obtain_from_rfc_and_cif(data.rfc, data.id_cif)
|
|
110
|
+
|
|
111
|
+
async def _obtain_html(self, uri: str) -> str:
|
|
112
|
+
try:
|
|
113
|
+
response = await self.client.get(uri)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
except httpx.HTTPError as exception:
|
|
116
|
+
raise CifDownloadException(uri) from exception
|
|
117
|
+
return response.text
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Value object de una CSF escaneada como imagen (JPEG o PNG)."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from csf_scraper.exceptions.image_reader import (
|
|
8
|
+
InvalidImageException,
|
|
9
|
+
InvalidImageFormatException,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
#: Firmas (magic bytes) de los formatos de imagen soportados.
|
|
13
|
+
_MIME_TYPES: dict[bytes, str] = {
|
|
14
|
+
b"\xff\xd8\xff": "image/jpeg",
|
|
15
|
+
b"\x89PNG\r\n\x1a\n": "image/png",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CsfImage:
|
|
20
|
+
"""Imagen de una Constancia de Situación Fiscal en formato JPEG o PNG.
|
|
21
|
+
|
|
22
|
+
Se construye desde una ruta (:meth:`new_from_path`) o desde bytes
|
|
23
|
+
(:meth:`new_from_contents`); en ambos casos se valida el formato por sus
|
|
24
|
+
*magic bytes* y se determina su tipo MIME.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_contents", "_mime_type")
|
|
28
|
+
|
|
29
|
+
def __init__(self, contents: bytes, mime_type: str) -> None:
|
|
30
|
+
self._contents = contents
|
|
31
|
+
self._mime_type = mime_type
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def new_from_path(cls, path: str) -> Self:
|
|
35
|
+
"""Lee y valida la imagen en ``path``; lanza :class:`InvalidImageException`."""
|
|
36
|
+
try:
|
|
37
|
+
contents = Path(path).read_bytes()
|
|
38
|
+
except OSError as exception:
|
|
39
|
+
raise InvalidImageException(f"Cannot read image from path: {path}") from exception
|
|
40
|
+
return cls.new_from_contents(contents)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def new_from_contents(cls, contents: bytes) -> Self:
|
|
44
|
+
"""Valida ``contents`` como imagen soportada y detecta su tipo MIME."""
|
|
45
|
+
if not contents:
|
|
46
|
+
raise InvalidImageException("The image is empty.")
|
|
47
|
+
return cls(contents, cls._detect_mime_type(contents))
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _detect_mime_type(contents: bytes) -> str:
|
|
51
|
+
for signature, mime_type in _MIME_TYPES.items():
|
|
52
|
+
if contents.startswith(signature):
|
|
53
|
+
return mime_type
|
|
54
|
+
raise InvalidImageFormatException(
|
|
55
|
+
"The given file is not a supported image (expected JPEG or PNG).",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def as_bytes(self) -> bytes:
|
|
59
|
+
"""Contenido binario de la imagen."""
|
|
60
|
+
return self._contents
|
|
61
|
+
|
|
62
|
+
def as_base64(self) -> str:
|
|
63
|
+
"""Contenido de la imagen codificado en base64."""
|
|
64
|
+
return base64.b64encode(self._contents).decode("ascii")
|
|
65
|
+
|
|
66
|
+
def get_mime_type(self) -> str:
|
|
67
|
+
"""Tipo MIME detectado (``image/jpeg`` o ``image/png``)."""
|
|
68
|
+
return self._mime_type
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Value object del idCIF (identificador del CIF del SAT)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
from pydantic_core import CoreSchema, core_schema
|
|
7
|
+
|
|
8
|
+
from csf_scraper.exceptions.value_objects import InvalidIdCifFormatException
|
|
9
|
+
|
|
10
|
+
#: Entre 8 y 13 dígitos (los idCIF observados van de 8 a 13 posiciones).
|
|
11
|
+
_PATTERN = re.compile(r"\d{8,13}\Z")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IdCif(str):
|
|
15
|
+
"""idCIF validado (de 8 a 13 dígitos).
|
|
16
|
+
|
|
17
|
+
Se construye con ``IdCif("1904014102123")``; lanza
|
|
18
|
+
:class:`~csf_scraper.exceptions.value_objects.InvalidIdCifFormatException` si el
|
|
19
|
+
valor no cumple el formato.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
def __new__(cls, value: Any) -> Self:
|
|
25
|
+
if isinstance(value, cls):
|
|
26
|
+
return value
|
|
27
|
+
text = value.strip() if isinstance(value, str) else value
|
|
28
|
+
if not isinstance(text, str) or _PATTERN.fullmatch(text) is None:
|
|
29
|
+
raise InvalidIdCifFormatException(f"Invalid idCIF format: {value!r}")
|
|
30
|
+
return super().__new__(cls, text)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def ensure(cls, value: Any) -> Self:
|
|
34
|
+
"""Convierte ``value`` a :class:`IdCif` validando su formato."""
|
|
35
|
+
return cls(value)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def value(self) -> str:
|
|
39
|
+
"""idCIF como ``str`` plano (sin la subclase :class:`IdCif`)."""
|
|
40
|
+
return str(self)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> CoreSchema:
|
|
44
|
+
return core_schema.no_info_after_validator_function(
|
|
45
|
+
cls,
|
|
46
|
+
core_schema.str_schema(),
|
|
47
|
+
serialization=core_schema.to_string_ser_schema(),
|
|
48
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Value object del RFC (Registro Federal de Contribuyentes)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
from pydantic_core import CoreSchema, core_schema
|
|
7
|
+
|
|
8
|
+
from csf_scraper.exceptions.value_objects import InvalidRfcFormatException
|
|
9
|
+
|
|
10
|
+
#: Alfanumérico: de 3 a 4 letras (incluye ``Ñ`` y ``&``), 6 dígitos de fecha y 3 de homoclave.
|
|
11
|
+
_PATTERN = re.compile(r"[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\Z")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Rfc(str):
|
|
15
|
+
"""RFC validado de una persona física (13 caracteres) o moral (12 caracteres).
|
|
16
|
+
|
|
17
|
+
Se construye con ``Rfc("DIM8701081LA")``; lanza
|
|
18
|
+
:class:`~csf_scraper.exceptions.value_objects.InvalidRfcFormatException` si el
|
|
19
|
+
valor no cumple el formato. Normaliza espacios y mayúsculas.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
def __new__(cls, value: Any) -> Self:
|
|
25
|
+
if isinstance(value, cls):
|
|
26
|
+
return value
|
|
27
|
+
text = value.strip().upper() if isinstance(value, str) else value
|
|
28
|
+
if not isinstance(text, str) or _PATTERN.fullmatch(text) is None:
|
|
29
|
+
raise InvalidRfcFormatException(f"Invalid RFC format: {value!r}")
|
|
30
|
+
return super().__new__(cls, text)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def ensure(cls, value: Any) -> Self:
|
|
34
|
+
"""Convierte ``value`` a :class:`Rfc` validando (y normalizando) su formato."""
|
|
35
|
+
return cls(value)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def value(self) -> str:
|
|
39
|
+
"""RFC como ``str`` plano (sin la subclase :class:`Rfc`)."""
|
|
40
|
+
return str(self)
|
|
41
|
+
|
|
42
|
+
def is_fisica(self) -> bool:
|
|
43
|
+
"""Indica si el RFC corresponde a una persona física (13 caracteres)."""
|
|
44
|
+
return len(self) == 13
|
|
45
|
+
|
|
46
|
+
def is_moral(self) -> bool:
|
|
47
|
+
"""Indica si el RFC corresponde a una persona moral (12 caracteres)."""
|
|
48
|
+
return not self.is_fisica()
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> CoreSchema:
|
|
52
|
+
return core_schema.no_info_after_validator_function(
|
|
53
|
+
cls,
|
|
54
|
+
core_schema.str_schema(),
|
|
55
|
+
serialization=core_schema.to_string_ser_schema(),
|
|
56
|
+
)
|