Metadata-Version: 2.4
Name: tai-storage
Version: 0.2.0
Summary: Librería para gestión unificada de archivos en múltiples orígenes de almacenamiento
Author: MateoSaezMata
Author-email: msaez@triplealpha.in
Requires-Python: >=3.10, <4.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Provides-Extra: all
Provides-Extra: analysis
Provides-Extra: azure
Provides-Extra: azure-identity
Provides-Extra: data
Provides-Extra: dev
Provides-Extra: googledrive
Provides-Extra: sharepoint
Requires-Dist: azure-identity (>=1.15.0) ; extra == "all"
Requires-Dist: azure-identity (>=1.15.0) ; extra == "azure-identity"
Requires-Dist: azure-storage-blob (>=12.0.0) ; extra == "all"
Requires-Dist: azure-storage-blob (>=12.0.0) ; extra == "azure"
Requires-Dist: azure-storage-blob (>=12.0.0) ; extra == "azure-identity"
Requires-Dist: google-auth (>=2.0.0) ; extra == "all"
Requires-Dist: google-auth (>=2.0.0) ; extra == "googledrive"
Requires-Dist: httpx (>=0.27,<1.0) ; extra == "all"
Requires-Dist: httpx (>=0.27,<1.0) ; extra == "googledrive"
Requires-Dist: httpx (>=0.27,<1.0) ; extra == "sharepoint"
Requires-Dist: hypothesis (>=6.0) ; extra == "dev"
Requires-Dist: jinja2 (>=3.1) ; extra == "all"
Requires-Dist: jinja2 (>=3.1) ; extra == "analysis"
Requires-Dist: msal (>=1.20.0) ; extra == "all"
Requires-Dist: msal (>=1.20.0) ; extra == "sharepoint"
Requires-Dist: numpy (>=1.24.0) ; extra == "all"
Requires-Dist: numpy (>=1.24.0) ; extra == "analysis"
Requires-Dist: numpy (>=1.24.0) ; extra == "data"
Requires-Dist: openpyxl (>=3.1.0) ; extra == "all"
Requires-Dist: openpyxl (>=3.1.0) ; extra == "data"
Requires-Dist: pandas (>=2.0.0,<3.0.0) ; (python_version < "3.11") and (extra == "all")
Requires-Dist: pandas (>=2.0.0,<3.0.0) ; (python_version < "3.11") and (extra == "analysis")
Requires-Dist: pandas (>=2.0.0,<3.0.0) ; (python_version < "3.11") and (extra == "data")
Requires-Dist: pandas (>=3.0.0) ; (python_version >= "3.11") and (extra == "all")
Requires-Dist: pandas (>=3.0.0) ; (python_version >= "3.11") and (extra == "analysis")
Requires-Dist: pandas (>=3.0.0) ; (python_version >= "3.11") and (extra == "data")
Requires-Dist: plotly (>=5.24) ; extra == "all"
Requires-Dist: plotly (>=5.24) ; extra == "analysis"
Requires-Dist: pyarrow (>=12.0.0) ; extra == "all"
Requires-Dist: pyarrow (>=12.0.0) ; extra == "data"
Requires-Dist: pytest (>=8.0) ; extra == "dev"
Requires-Dist: tai-alphi (>=2.1.0,<3.0)
Requires-Dist: xlrd (>=2.0.0) ; extra == "all"
Requires-Dist: xlrd (>=2.0.0) ; extra == "data"
Description-Content-Type: text/markdown

# tai-storage

**Los mismos ficheros, estén donde estén**: una sola API para el disco local, SharePoint Online,
Azure Blob Storage y Google Drive, con la misma semántica en los cuatro, y la lectura de CSV, Excel,
Parquet y JSON como DataFrames.

[![Python](https://img.shields.io/badge/python-3.10%E2%80%933.13-blue.svg)](https://www.python.org/downloads/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

```python
from tai_storage import StorageFactory

origin = StorageFactory.create_sharepoint()          # credenciales del entorno
for file in origin.get_folder("Ventas/2024").list_files(pattern=r"\.csv$"):
    print(file.filename, file.size, file.modified_time)
df = origin.get_file("Ventas/2024/enero.csv").get_data().to_dataframe(sep=";")
```

## Instalación

```bash
pip install tai-storage                  # núcleo y disco local
pip install "tai-storage[sharepoint]"    # + SharePoint (httpx, msal)
pip install "tai-storage[azure]"         # + Azure Blob (azure-storage-blob)
pip install "tai-storage[azure-identity]"  # + Azure con identidad de Azure AD, sin clave
pip install "tai-storage[googledrive]"   # + Google Drive (httpx, google-auth)
pip install "tai-storage[data]"          # + DataFrames (pandas, openpyxl, xlrd, pyarrow)
pip install "tai-storage[analysis]"      # + el informe HTML de un DataFrame (pandas, plotly, jinja2)
pip install "tai-storage[all]"           # todo
```

`import tai_storage` no necesita ningún extra: cada backend y el módulo de datos se cargan al
usarse, y si falta su extra el error dice qué instalar.

## Orígenes

| Backend | Crear | Variables de entorno |
|---|---|---|
| Local | `StorageFactory.create_local(root_path)` | `STORAGE_LOCAL_ROOT_PATH` |
| SharePoint | `StorageFactory.create_sharepoint()` | `SHAREPOINT_TENANT_ID`, `SHAREPOINT_CLIENT_ID`, `SHAREPOINT_CLIENT_SECRET`, `SHAREPOINT_SITE_NAME`, `SHAREPOINT_HOSTNAME`, opcional `SHAREPOINT_DRIVE` |
| Azure Blob | `StorageFactory.create_azure(container=...)` | `AZURE_STORAGE_CONNECTION_STRING` (o `AZURE_STORAGE_ACCOUNT_NAME` + `AZURE_STORAGE_ACCOUNT_KEY`), opcional `AZURE_STORAGE_CONTAINER` |
| Google Drive | `StorageFactory.create_googledrive()` | `GOOGLE_DRIVE_CREDENTIALS_PATH`, `GOOGLE_DRIVE_ROOT_FOLDER_ID`, opcional `GOOGLE_DRIVE_DRIVE_ID` |

Lo que no se pasa como argumento se lee del entorno al llamar; si falta, el error dice qué
variable definir. `auto_connect=True` (por defecto) comprueba ya las credenciales.

- **SharePoint**: una biblioteca de documentos de un sitio (la principal, o `drive="Nombre"`;
  `site_path="teams"` para sitios de Teams). Cómo dar de alta la aplicación de Azure AD y darle
  acceso al sitio: [docs/sharepoint-azure-ad.md](docs/sharepoint-azure-ad.md).
- **Azure**: con `container=` ese container es la raíz del origen (lo habitual para backups;
  `create_container=True` lo crea). Sin él, el primer segmento de cada ruta es el container. En
  una Web App de Azure, `account_url=` sin clave usa su identidad administrada (extra
  `azure-identity`).
- **Google Drive**: una cuenta de servicio no tiene cuota en «Mi unidad»: la raíz tiene que ser
  una carpeta de una unidad compartida en la que la cuenta sea gestora de contenido.

## La API

```python
folder = origin.get_folder("informes/2024")     # no habla con el servicio
file = folder.get_file("enero.csv")             # tampoco

file.write(b"...")                  # sobrescribe; crea las carpetas que falten
file.write_text("...")              # ídem, con texto
folder.upload_file(fuente, "a.csv") # fuente: bytes, la ruta de un fichero local o un stream;
                                    # NO sobrescribe salvo overwrite=True
data = file.read()                  # todo en memoria
with file.open_read() as stream:    # o por streaming
    ...
file.download_to_local("a.csv")     # por trozos y de forma atómica

folder.list_files(pattern=r"\.csv$", recursive=True, modified_after=datetime.now() - timedelta(days=7))
folder.list_folders()
file.size, file.modified_time, file.info    # FileInfo; si viene de un listado, sin más peticiones

file.copy_to(otro_file)             # también a otro origen; no sobrescribe salvo overwrite=True
file.move_to(otro_file)             # entre orígenes: copia, comprueba el tamaño y borra
file.delete()
folder.create()                     # falla si existe
folder.delete()                     # falla si tiene contenido; recursive=True lo borra todo
```

Las reglas son **las mismas en los cuatro backends** (y las comprueba una misma batería de tests
contra cada uno):

- Rutas relativas a la raíz del origen; `''`, `'.'` y `'/'` son la raíz. `..` no se acepta.
- `pattern` es una expresión regular buscada en el **nombre** del fichero, sin distinguir
  mayúsculas.
- Todas las fechas llevan zona horaria (UTC). En los filtros, una fecha sin zona es hora local.
- Los ficheros grandes van por trozos, sin cargarlos en memoria: subir y bajar 1 GB hace crecer
  la memoria unos 20 MB (medido con 1 GB en Local y Azure, y 200 MB en SharePoint).
- En Azure no hay carpetas vacías: una carpeta existe mientras tenga algún fichero
  (`origin.capabilities.empty_folders`).

### Errores

Toda operación devuelve lo que promete o lanza un `TaiStorageError` con **qué ha pasado y qué
hacer**:

```python
from tai_storage import FileNotFoundError, TaiStorageError

try:
    file.read()
except FileNotFoundError as error:   # también lo captura un except FileNotFoundError de Python
    print(error.message, error.solution)
```

| Familia | Cuándo |
|---|---|
| `ConfigurationError` | Falta una credencial o variable, o no es válida |
| `AuthenticationError` / `PermissionDeniedError` | Credenciales rechazadas / sin permiso |
| `FileNotFoundError`, `FolderNotFoundError` | No existe |
| `FileAlreadyExistsError`, `FolderAlreadyExistsError` | Existe y no se pidió sobrescribir |
| `FolderNotEmptyError` | Borrar sin `recursive=True` una carpeta con contenido |
| `InvalidPathError` | La ruta sale del origen o está mal formada |
| `ConflictError` | Ambigüedad en el servicio (dos ficheros con el mismo nombre en Drive) |
| `StorageUnavailableError` | Red, tiempo de espera, 5xx o 429 tras agotar los reintentos |

Los servicios remotos reintentan solos lo que se puede reintentar (429 y 5xx, respetando
`Retry-After`) y renuevan el token cuando caduca.

### `dev_mode`

```python
origin = StorageFactory.create_sharepoint(dev_mode=True)    # caché en .cached_storage/
```

Guarda una copia local de lo que se lee, para no descargar lo mismo en cada ejecución mientras se
desarrolla. Tiene exactamente la semántica del origen: solo cambia de dónde salen los bytes al
leer. Cada lectura comprueba con el remoto (una petición) si la copia sigue valiendo.
`origin.clear_cache()` la borra.

## DataFrames

```python
df = file.get_data().to_dataframe()                              # por la extensión
df = file.get_data().to_dataframe_from_csv(with_fields=["id", "importe"])
df = file.get_data().to_dataframe_from_excel(sheet_name="Ventas", entrypoint="Código")

from tai_storage.data import read, read_with_report, Column
df, report = read_with_report(file, columns={"cp": "string", "importe": "float"})
print(report)                                                    # qué se leyó, tipos y avisos
```

CSV, Excel (`.xlsx`, `.xls`), JSON y Parquet, con números y fechas en formato español por defecto.
Cada fila y cada columna del fichero acaba en el DataFrame o hay un error; la inferencia de tipos
nunca introduce un nulo (lo que no se puede convertir se queda como texto y se dice por qué), y un
valor que no encaja con el tipo declarado es un error que dice su línea o su celda. Ver
[tai_storage/data/README.md](tai_storage/data/README.md).

## El informe de un DataFrame

```python
from tai_storage.analysis import analyze

report = analyze(df, title="Ventas 2024", key="id_venta",
                 references={"id_cliente": (clientes, "id", "clientes")})
report.save(folder.get_file("informes/ventas.html"))    # o una ruta local
report.alerts                                          # lo raro, por gravedad
```

Un HTML que se abre sin internet (Plotly va dentro) con el resumen, los avisos (nulos,
constantes, duplicados, texto que parece número o fecha, ceros a la izquierda, atípicos, clave
repetida, valores que faltan en otra tabla), una ficha por columna y las relaciones entre
columnas. Separado de la lectura: recibe un DataFrame y no infiere tipos. Ver
[tai_storage/analysis/README.md](tai_storage/analysis/README.md).

## Reglas para asistentes de IA

```bash
tai-storage rules install     # en .claude/rules/_platform/tai-storage/ de tu repositorio
tai-storage rules check       # ¿están al día con la versión instalada? (útil en CI)
```

Instala en el proyecto lo que su asistente necesita saber de tai-storage —cómo se usa, qué
garantiza `get_data()` y qué significa cada error— para **la versión que el proyecto tiene
instalada**. Lo que se haya editado a mano no se pisa sin `--force`.

## De la 0.1 a la 0.2

| 0.1 | 0.2 |
|---|---|
| `StorageFactory.set_dev_mode(True)` | `dev_mode=True` en cada `create_*` |
| `upload_file` con `dev_mode` sobrescribía | nunca sin `overwrite=True` |
| `delete(recursive=False)` en SharePoint, Drive o un container de Azure lo borraba todo | `FolderNotEmptyError` |
| `get_folder()` comprobaba que existía en los remotos | no habla con el servicio; lo que no existe falla al usarse |
| `get_metadata()` devolvía un `dict` | devuelve `FileInfo`/`FolderInfo` (`.to_dict()` da las claves de antes) |
| Local: fechas sin zona; `Folder.path` absoluto; `pattern` con `re.match` | fechas en UTC; rutas relativas al origen (`LocalFile.local_path` es la absoluta); `re.search` |
| `LocalOrigin(root)` creaba la carpeta | la crea `connect()` con `create=True` (lo que hace `create_local`) |
| `UploadError`, `WriteError`, `ValidationError` | `FileAlreadyExistsError`, `StorageUnavailableError`, `InvalidRequestError` |
| `requests` y `google-api-python-client` | `httpx` (extras `sharepoint` y `googledrive`) |
| `data`: la inferencia perdía decimales con coma, ceros a la izquierda y fechas; `with_fields` que no existía, un WARNING | inferencia conservadora y formato español; `MissingColumnsError`, `ColumnTypeError`, `DataFormatError` |
| `data`: `CSVReader`, `ExcelReader`, `TypeInferencer`, `df.profile` | `read()`, `read_with_report()`, `Column`; `df.profile` pasa a `tai_storage.analysis.analyze(df)` (extra `analysis`) |
| `data`: un CSV se leía con `sep=','` por defecto | el separador se detecta |
| `to_dataframe_from_excel()` leía la hoja `Hoja1` por defecto | la primera hoja |

## Desarrollo

```bash
pip install -e ".[all,dev]"
pytest -m "not sharepoint and not azure and not googledrive and not azurite"
```

Cómo está montado y qué no romper: [CLAUDE.md](CLAUDE.md) y [.claude/rules/](.claude/rules/).
Lo que se está construyendo: [specs/](specs/README.md).

## Autores

- **Mateo Saez Mata** — [msaez@triplealpha.in](mailto:msaez@triplealpha.in)

