From 581aa551c4bd1b7c8ae902a08d568c10803b2955 Mon Sep 17 00:00:00 2001 From: Miguel Date: Mon, 1 Sep 2025 17:13:38 +0200 Subject: [PATCH] Problemas --- README.md => .docs/README.md | 2 +- .docs/SPECIFICATION.md | 258 ++++++++++++++ .docs/TECHNICAL_QUESTIONS.md | 124 +++++++ .docs/config.json | 60 ++++ .docs/projects.json | 62 ++++ .docs/requirements.txt | 32 ++ autobackups/.gitignore | 216 ++++++++++++ autobackups/README.md | 72 ++++ autobackups/TECHNICAL_QUESTIONS.md | 124 +++++++ autobackups/src/app.py | 275 +++++++++++++++ autobackups/src/models/__init__.py | 9 + autobackups/src/models/config_model.py | 167 +++++++++ autobackups/src/models/project_model.py | 265 ++++++++++++++ autobackups/src/services/__init__.py | 12 + .../src/services/basic_backup_service.py | 94 +++++ .../src/services/project_discovery_service.py | 325 ++++++++++++++++++ autobackups/src/utils/__init__.py | 10 + autobackups/src/utils/everything_wrapper.py | 272 +++++++++++++++ autobackups/src/utils/file_utils.py | 247 +++++++++++++ autobackups/src/utils/hash_utils.py | 197 +++++++++++ 20 files changed, 2822 insertions(+), 1 deletion(-) rename README.md => .docs/README.md (99%) create mode 100644 .docs/SPECIFICATION.md create mode 100644 .docs/TECHNICAL_QUESTIONS.md create mode 100644 .docs/config.json create mode 100644 .docs/projects.json create mode 100644 .docs/requirements.txt create mode 100644 autobackups/.gitignore create mode 100644 autobackups/README.md create mode 100644 autobackups/TECHNICAL_QUESTIONS.md create mode 100644 autobackups/src/app.py create mode 100644 autobackups/src/models/__init__.py create mode 100644 autobackups/src/models/config_model.py create mode 100644 autobackups/src/models/project_model.py create mode 100644 autobackups/src/services/__init__.py create mode 100644 autobackups/src/services/basic_backup_service.py create mode 100644 autobackups/src/services/project_discovery_service.py create mode 100644 autobackups/src/utils/__init__.py create mode 100644 autobackups/src/utils/everything_wrapper.py create mode 100644 autobackups/src/utils/file_utils.py create mode 100644 autobackups/src/utils/hash_utils.py diff --git a/README.md b/.docs/README.md similarity index 99% rename from README.md rename to .docs/README.md index a48737d..4ea8ed7 100644 --- a/README.md +++ b/.docs/README.md @@ -14,7 +14,7 @@ AutoBackups is a Python application designed to automate the backup of Simatic S ## Project Structure ``` - +autobackups ├── .logs/ # Application logs with startup timestamps ├── src │ ├── app.py # Main entry point of the Flask application diff --git a/.docs/SPECIFICATION.md b/.docs/SPECIFICATION.md new file mode 100644 index 0000000..c3e7a33 --- /dev/null +++ b/.docs/SPECIFICATION.md @@ -0,0 +1,258 @@ +# AutoBackups - Especificación Técnica Detallada + +## Resumen Ejecutivo +AutoBackups es una aplicación Python que automatiza el backup de proyectos Simatic S7 y directorios definidos por el usuario, utilizando la API de Everything para búsqueda eficiente de archivos y un sistema de hash inteligente para evitar backups innecesarios. + +## Arquitectura del Sistema + +### 1. Componentes Principales + +#### 1.1 Motor de Búsqueda (Everything API) +- **Librería**: PyEverything wrapper +- **DLL Local**: `Everything-SDK\dll\Everything64.dll` +- **Función**: Localizar archivos *.s7p en directorios de observación +- **Optimización**: Evitar último nivel del árbol para archivos .s7p +- **Ventaja**: Aprovecha el índice existente de Everything para búsquedas instantáneas + +#### 1.2 Sistema de Hash en Dos Etapas +**Etapa 1**: Hash rápido de archivos *.s7p +- Solo verifica MD5 de (timestamp + tamaño) de archivos .s7p +- Si no hay cambios → no se procede con backup +- Si hay cambios → procede a Etapa 2 + +**Etapa 2**: Hash completo del proyecto +- Calcula MD5 de (timestamp + tamaño) de todos los archivos del directorio +- Compara con último hash almacenado +- Solo hace backup si hay diferencias + +#### 1.3 Scheduler de Background +- **daily**: Una vez al día a hora configurada +- **hourly**: Cada hora +- **3-hour**: Cada 3 horas +- **7-hour**: Cada 7 horas +- **startup**: Al iniciar la aplicación +- **manual**: Solo bajo demanda del usuario +- **Escaneos**: Automáticos cada 1 hora +- **Backups**: Mínimo cada 10 minutos +- **Prioridad**: Baja prioridad de sistema + +### 2. Estructura de Datos + +#### 2.1 config.json (Configuración Global) +```json +{ + "observation_directories": [ + { + "path": "C:\\Projects\\Siemens", + "type": "siemens_s7", + "enabled": true + }, + { + "path": "D:\\Engineering\\Projects", + "type": "siemens_s7", + "enabled": true + }, + { + "path": "C:\\Important\\Docs", + "type": "manual", + "enabled": true + } + ], + "backup_destination": "D:\\Backups\\AutoBackups", + "default_schedule": "daily", + "default_schedule_time": "02:00", + "retry_delay_hours": 1, + "web_interface": { + "host": "127.0.0.1", + "port": 5000 + }, + "logging": { + "level": "INFO", + "max_log_files": 30 + } +} +``` + +#### 2.2 projects.json (Estado de Proyectos) +```json +{ + "projects": [ + { + "id": "project_001", + "name": "PLC_MainLine", + "path": "C:\\Projects\\Siemens\\PLC_MainLine", + "type": "siemens_s7", + "s7p_file": "C:\\Projects\\Siemens\\PLC_MainLine\\PLC_MainLine.s7p", + "schedule": "daily", + "schedule_time": "02:00", + "enabled": true, + "last_backup": "2025-09-01T02:15:30", + "last_s7p_hash": "abc123def456", + "last_full_hash": "def789ghi012", + "last_s7p_timestamp": "2025-08-31T14:30:00", + "status": "ready", + "retry_count": 0, + "next_retry": null, + "error_message": null + } + ] +} +``` + +### 3. Flujo de Operación + +#### 3.1 Inicio de Aplicación +1. Cargar configuración desde `config.json` +2. Cargar estado de proyectos desde `projects.json` +3. Inicializar logger con timestamp de inicio +4. Escanear directorios de observación usando Everything API +5. Actualizar lista de proyectos detectados +6. Iniciar scheduler +7. Iniciar interfaz web Flask + +#### 4.2 Proceso de Backup +1. **Verificación de Espacio**: Verificar mínimo 100MB libres +2. **Verificación de Acceso .s7p**: Intentar comprimir solo archivo .s7p +3. **Hash Etapa 1**: Verificar MD5 de (timestamp + tamaño) del archivo .s7p +4. **Hash Etapa 2**: Si Etapa 1 detecta cambios, verificar hash completo +5. **Compresión**: Crear archivo ZIP con estructura específica preservada +6. **Almacenamiento**: Guardar en `backup_destination/ProjectPath/YYYY-MM-DD/HH-MM-SS_projects.zip` +7. **Actualización**: Actualizar hashes y timestamps en `projects.json` + +#### 3.3 Gestión de Errores +- **Archivos en uso**: Marcar proyecto para reintento en 1 hora +- **Errores de permisos**: Log de error y notificación en interfaz +- **Errores de espacio**: Verificar espacio disponible antes de backup + +### 4. Interfaz Web + +#### 4.1 Dashboard Principal +- **Lista de Proyectos**: Tabla con estado, último backup, próximo backup +- **Controles Globales**: Backup manual global, pausar/reanudar scheduler +- **Estadísticas**: Total de proyectos, backups exitosos, errores + +#### 4.2 Configuración de Proyectos +- **Habilitación/Deshabilitación**: Toggle por proyecto +- **Configuración de Schedule**: Dropdown con opciones de frecuencia +- **Backup Manual**: Botón de backup inmediato por proyecto + +#### 4.3 Logs y Monitoreo +- **Log Viewer**: Mostrar logs recientes con filtros por nivel +- **Estado del Sistema**: Información de Everything API, espacio en disco + +### 5. Estructura de Backup + +#### 5.1 Nomenclatura de Archivos (Estructura Específica) +``` +backup_destination/ +├── LineA/ +│ └── Project1/ +│ ├── 2025-09-01/ +│ │ ├── 02-15-30_projects.zip +│ │ └── 14-30-15_projects.zip +│ └── 2025-09-02/ +│ └── 02-15-45_projects.zip +└── LineB/ + └── Project2/ + └── 2025-09-01/ + └── 08-20-10_projects.zip +``` + +#### 5.2 Contenido del ZIP +``` +02-15-30_projects.zip +└── LineA/ + └── Project1/ + ├── project.s7p + ├── subfolder1/ + └── subfolder2/ +``` + +### 6. Tecnologías y Librerías + +#### 6.1 Python Core +- **Python**: 3.12 +- **Framework Web**: Flask +- **Scheduler**: APScheduler +- **Compresión**: zipfile (biblioteca estándar) + +#### 6.2 Librerías Específicas +- **Everything API**: PyEverything wrapper con DLL local +- **Hashing**: hashlib MD5 para (timestamp + tamaño) +- **Config**: json (biblioteca estándar) +- **Logging**: logging (biblioteca estándar) +- **File Access Check**: Intento de compresión + verificación .s7p +- **Process Priority**: pywin32 para baja prioridad +- **Disk Space**: psutil para monitoreo de espacio libre + +#### 6.3 Frontend +- **HTML/CSS/JavaScript**: Vanilla (sin React por simplicidad) +- **AJAX**: Para comunicación con Flask API +- **Bootstrap**: Para estilizado responsivo + +### 7. Consideraciones de Seguridad + +#### 7.1 Acceso a Archivos +- Verificación de permisos antes de backup +- Manejo seguro de rutas para evitar path traversal +- Logs de todos los accesos a archivos + +#### 7.2 Interfaz Web +- Solo acceso local (127.0.0.1) +- No autenticación requerida (uso local únicamente) +- Validación de inputs en formularios + +### 8. Logging y Monitoreo + +#### 8.1 Estructura de Logs +``` +.logs/ +├── autobackups_2025-09-01_08-30-15.log +├── autobackups_2025-09-02_08-30-22.log +└── ... +``` + +#### 8.2 Niveles de Log +- **INFO**: Inicio/fin de backups, descubrimiento de proyectos +- **WARNING**: Archivos en uso, reintentos +- **ERROR**: Fallos de backup, errores de configuración +- **DEBUG**: Detalles de hashes, operaciones de archivos + +### 9. Instalación y Despliegue + +#### 9.1 Entorno Miniconda +```bash +conda create --name autobackups python=3.12 +conda activate autobackups +pip install -r requirements.txt +``` + +#### 9.2 Ejecución +- **Desarrollo**: `python src/app.py` +- **Producción**: `pythonw.exe src/app.py` (sin consola) +- **Como servicio**: Futuro enhancement con `python-windows-service` + +### 10. Roadmap de Desarrollo + +#### Fase 1: Core Backend +- [ ] Integración con Everything API +- [ ] Sistema de hash en dos etapas +- [ ] Motor de backup básico +- [ ] Scheduler con APScheduler + +#### Fase 2: Interfaz Web +- [ ] Dashboard básico con Flask +- [ ] API REST para operaciones CRUD +- [ ] Frontend con HTML/JS/Bootstrap + +#### Fase 3: Características Avanzadas +- [ ] Logs web viewer +- [ ] Configuración avanzada de directorios +- [ ] Estadísticas y reportes + +#### Fase 4: Optimizaciones +- [ ] Ejecución como servicio Windows +- [ ] Notificaciones por email +- [ ] Backup incremental + +Este documento será la base para el desarrollo del proyecto AutoBackups. diff --git a/.docs/TECHNICAL_QUESTIONS.md b/.docs/TECHNICAL_QUESTIONS.md new file mode 100644 index 0000000..f9a93f6 --- /dev/null +++ b/.docs/TECHNICAL_QUESTIONS.md @@ -0,0 +1,124 @@ +# AutoBackups - Preguntas Técnicas Pendientes + +## Preguntas Críticas para el Desarrollo + +### 1. Everything API - Integración +**Pregunta**: ¿Everything está instalado y funcionando en el sistema objetivo? +**Impacto**: Si no está disponible, necesitaremos un método alternativo de búsqueda de archivos. +**Opciones**: +- Usar `python-everything` si Everything está disponible +- Implementar búsqueda recursiva con `os.walk()` como fallback +- Usar `pathlib` con `glob` patterns + +**Decisión necesaria**: ¿Podemos asumir que Everything estará disponible o necesitamos fallback? + +### 2. Estructura de Backup - Clarificación de Rutas +**Pregunta**: Si tenemos un archivo en `C:\Projects\Siemens\LineA\Project1\project.s7p`, ¿el backup debe ser? +**Opción A**: +``` +backup_destination/2025-09-01_14-30-15/Projects_Siemens_LineA_Project1.zip +└── Projects/Siemens/LineA/Project1/... +``` +**Opción B**: +``` +backup_destination/2025-09-01_14-30-15/LineA_Project1.zip +└── LineA/Project1/... +``` + +**Decisión necesaria**: ¿Qué estructura prefieres? + +### 3. Configuración de Schedules - Granularidad +**Pregunta**: ¿Los schedules deben ser configurables a nivel de: +- Por directorio de observación (todos los proyectos de un directorio) +- Por proyecto individual +- Ambos (global con override por proyecto) + +**Decisión necesaria**: ¿Qué nivel de granularidad necesitas? + +### 4. Hash de Archivos - Algoritmo y Almacenamiento +**Pregunta**: Para el sistema de hash en dos etapas: +- ¿Usamos MD5 (rápido) o SHA256 (más seguro) para los hashes? +- ¿El hash incluye solo timestamp o también tamaño de archivo? +- ¿Cómo manejamos archivos que se mueven dentro del proyecto? + +**Decisión necesaria**: ¿Qué balance entre velocidad y precisión prefieres? + +### 5. Detección de Archivos en Uso - Método +**Pregunta**: Para detectar archivos bloqueados, ¿prefieres: +**Opción A**: Intentar abrir cada archivo en modo escritura exclusiva +**Opción B**: Usar `lsof` (Linux) / `handle.exe` (Windows) para listar archivos abiertos +**Opción C**: Solo verificar el archivo .s7p principal + +**Decisión necesaria**: ¿Qué método consideras más apropiado? + +### 6. Manejo de Proyectos Grandes - Performance +**Pregunta**: ¿Hay límites que debamos considerar? +- Tamaño máximo de proyecto para backup +- Tiempo máximo de backup +- Número máximo de archivos por proyecto + +**Decisión necesaria**: ¿Necesitamos algún tipo de throttling o límites? + +### 7. Configuración de Directorios - Recursividad +**Pregunta**: Cuando especificamos un directorio de observación como `C:\Projects`, ¿debemos: +- Buscar solo en subdirectorios inmediatos +- Buscar recursivamente en toda la jerarquía +- Permitir configurar la profundidad de búsqueda + +**Decisión necesaria**: ¿Qué comportamiento prefieres por defecto? + +### 8. Backup Incremental vs Completo +**Pregunta**: ¿Todos los backups deben ser completos o consideras backup incremental? +- Backup completo: Todo el proyecto cada vez +- Backup incremental: Solo archivos modificados + +**Decisión necesaria**: ¿Preferencia por simplicidad o eficiencia de espacio? + +### 9. Web Interface - Características Específicas +**Pregunta**: ¿Qué funcionalidades son prioritarias en la interfaz web? +**Must-have**: +- Lista de proyectos con estado +- Trigger manual de backup +- Ver logs básicos + +**Nice-to-have**: +- Configurar schedules por proyecto +- Ver progreso de backup en tiempo real +- Estadísticas históricas +- Configurar nuevos directorios + +**Decisión necesaria**: ¿Cuáles son must-have vs nice-to-have? + +### 10. Error Handling - Estrategias de Recuperación +**Pregunta**: ¿Cómo manejar errores específicos? +- Espacio insuficiente en destino de backup +- Pérdida de conexión con Everything +- Corrupción de archivos de configuración +- Falla durante compresión + +**Decisión necesaria**: ¿Qué nivel de robustez necesitas? + +### 11. Startup Behavior - Inicialización +**Pregunta**: Al iniciar la aplicación: +- ¿Debe hacer un escaneo completo inmediatamente? +- ¿Debe esperar al primer schedule programado? +- ¿Debe permitir configurar el comportamiento de startup? + +**Decisión necesaria**: ¿Qué comportamiento prefieres al iniciar? + +### 12. Multi-threading - Concurrencia +**Pregunta**: ¿Los backups deben ejecutarse: +- Secuencialmente (uno por vez) +- En paralelo (múltiples simultáneos) +- Con límite configurable de concurrencia + +**Decisión necesaria**: ¿Qué balance entre velocidad y recursos del sistema? + +## Próximos Pasos +Una vez que tengas respuestas a estas preguntas, podremos: +1. Finalizar el diseño técnico +2. Crear el `requirements.txt` definitivo +3. Comenzar con la implementación por fases +4. Definir el plan de testing + +¿Podrías revisar estas preguntas y darme tus preferencias para cada una? diff --git a/.docs/config.json b/.docs/config.json new file mode 100644 index 0000000..97748ef --- /dev/null +++ b/.docs/config.json @@ -0,0 +1,60 @@ +{ + "observation_directories": [ + { + "path": "C:\\Projects\\Siemens", + "type": "siemens_s7", + "enabled": true, + "description": "Directorio principal de proyectos Siemens" + }, + { + "path": "D:\\Engineering\\Projects", + "type": "siemens_s7", + "enabled": true, + "description": "Proyectos de ingeniería adicionales" + }, + { + "path": "C:\\Important\\Documentation", + "type": "manual", + "enabled": true, + "description": "Documentación importante para backup manual" + } + ], + "backup_destination": "D:\\Backups\\AutoBackups", + "global_settings": { + "default_schedule": "daily", + "default_schedule_time": "02:00", + "retry_delay_hours": 1, + "backup_timeout_minutes": 0, + "min_free_space_mb": 100, + "scan_interval_minutes": 60, + "min_backup_interval_minutes": 10 + }, + "everything_api": { + "dll_path": "Everything-SDK\\dll\\Everything64.dll", + "enabled": true, + "search_depth": -1, + "skip_last_level_for_s7p": true + }, + "web_interface": { + "host": "127.0.0.1", + "port": 5000, + "debug": false + }, + "logging": { + "level": "INFO", + "max_log_files": 30, + "log_rotation_days": 30 + }, + "backup_options": { + "compression_level": 6, + "include_subdirectories": true, + "preserve_directory_structure": true, + "hash_algorithm": "md5", + "hash_includes": ["timestamp", "size"], + "backup_type": "complete", + "process_priority": "low", + "sequential_execution": true, + "filename_format": "HH-MM-SS_projects.zip", + "date_format": "YYYY-MM-DD" + } +} diff --git a/.docs/projects.json b/.docs/projects.json new file mode 100644 index 0000000..3c68e69 --- /dev/null +++ b/.docs/projects.json @@ -0,0 +1,62 @@ +{ + "metadata": { + "version": "1.0", + "last_updated": "2025-09-01T08:30:15Z", + "total_projects": 0 + }, + "projects": [ + { + "id": "example_project_001", + "name": "PLC_MainLine_Example", + "path": "C:\\Projects\\Siemens\\LineA\\Project1", + "type": "siemens_s7", + "s7p_file": "C:\\Projects\\Siemens\\LineA\\Project1\\project.s7p", + "observation_directory": "C:\\Projects\\Siemens", + "relative_path": "LineA\\Project1", + "backup_path": "LineA\\Project1", + "schedule_config": { + "schedule": "daily", + "schedule_time": "02:00", + "enabled": true, + "next_scheduled_backup": "2025-09-02T02:00:00Z" + }, + "backup_history": { + "last_backup_date": "2025-09-01T02:15:30Z", + "last_backup_file": "D:\\Backups\\AutoBackups\\LineA\\Project1\\2025-09-01\\02-15-30_projects.zip", + "backup_count": 5, + "last_successful_backup": "2025-09-01T02:15:30Z" + }, + "hash_info": { + "last_s7p_hash": "abc123def456789", + "last_full_hash": "def789ghi012345", + "last_s7p_timestamp": "2025-08-31T14:30:00Z", + "last_s7p_size": 2048576, + "last_scan_timestamp": "2025-09-01T02:10:00Z", + "file_count": 1247, + "total_size_bytes": 125847296 + }, + "status": { + "current_status": "ready", + "last_error": null, + "retry_count": 0, + "next_retry": null, + "files_in_use": false, + "exclusivity_check_passed": true, + "last_status_update": "2025-09-01T02:15:35Z" + }, + "discovery_info": { + "discovered_date": "2025-09-01T08:30:15Z", + "discovery_method": "everything_api", + "auto_discovered": true + } + } + ], + "statistics": { + "total_backups_created": 15, + "total_backup_size_mb": 2450.5, + "average_backup_time_seconds": 45.2, + "last_global_scan": "2025-09-01T08:30:15Z", + "projects_with_errors": 0, + "projects_pending_retry": 0 + } +} diff --git a/.docs/requirements.txt b/.docs/requirements.txt new file mode 100644 index 0000000..c0139b7 --- /dev/null +++ b/.docs/requirements.txt @@ -0,0 +1,32 @@ +# Core Framework +Flask==2.3.3 +Werkzeug==2.3.7 + +# Scheduling +APScheduler==3.10.4 + +# Everything API Integration +PyEverything==1.0.1 # Wrapper para Everything SDK + +# File Operations and Utilities +pathlib2==2.3.7.post1 # Enhanced pathlib for Python 3.12 +psutil==5.9.5 # System utilities (disk space monitoring) +filelock==3.12.4 # File locking for concurrent access + +# Web Interface +Jinja2==3.1.2 # Template engine for Flask + +# Configuration and Data +jsonschema==4.19.1 # JSON schema validation + +# Logging +colorlog==6.7.0 # Colored logging output + +# Windows-specific dependencies +pywin32==306; sys_platform == "win32" # Windows API access for low priority + +# Development and Testing (opcional, remove for production) +pytest==7.4.2 +pytest-flask==1.2.0 +black==23.9.1 # Code formatting +flake8==6.1.0 # Code linting diff --git a/autobackups/.gitignore b/autobackups/.gitignore new file mode 100644 index 0000000..5290d13 --- /dev/null +++ b/autobackups/.gitignore @@ -0,0 +1,216 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/autobackups/README.md b/autobackups/README.md new file mode 100644 index 0000000..4ea8ed7 --- /dev/null +++ b/autobackups/README.md @@ -0,0 +1,72 @@ +# AutoBackups Project + +## Overview +AutoBackups is a Python application designed to automate the backup of Simatic S7 projects and other user-defined directories. The application leverages the Everything API to efficiently locate files and manage backups based on user-defined configurations. + +## Features +- Monitors specified directories for Simatic S7 files (*.s7p) and other user-defined directories. +- Automatically compresses and backs up projects on a configurable schedule (daily, hourly, 3-hour, 7-hour intervals, startup, or manual). +- Supports manual backup triggers. +- Skips projects with files in use and retries after one hour. +- Maintains a two-stage hash system: first checking *.s7p files, then all files to avoid unnecessary backups. +- User-friendly web interface built with Flask for configuration and status monitoring. +- Comprehensive logging system with timestamped log files. + +## Project Structure +``` +autobackups +├── .logs/ # Application logs with startup timestamps +├── src +│ ├── app.py # Main entry point of the Flask application +│ ├── models +│ │ └── __init__.py # Data models for configuration and directories +│ ├── routes +│ │ └── __init__.py # API routes for backup operations +│ ├── services +│ │ └── __init__.py # Business logic for backups +│ └── utils +│ └── __init__.py # Utility functions for hashing and file access +├── static +│ ├── css # CSS files for styling +│ └── js # JavaScript files for client-side functionality +├── templates +│ └── index.html # Main HTML template for the web interface +├── config.json # Global configuration settings for the application +├── projects.json # Per-project configuration, schedules, and hash storage +├── requirements.txt # Python dependencies +└── README.md # Project documentation +``` + +## Installation +1. Clone the repository: + ``` + git clone + cd autobackups + ``` + +2. Create a Miniconda environment: + ``` + conda create --name autobackups python=3.12 + conda activate autobackups + ``` + +3. Install the required packages: + ``` + pip install -r requirements.txt + ``` + +4. Configure the application by editing `config.json` to specify directories to monitor and backup settings. + +## Usage +- Start the Flask application: + ``` + python src/app.py + ``` + +- Access the web interface at `http://localhost:5000` to configure settings and monitor backup statuses. + +## Contributing +Contributions are welcome! Please submit a pull request or open an issue for any enhancements or bug fixes. + +## License +This project is licensed under the MIT License. See the LICENSE file for details. \ No newline at end of file diff --git a/autobackups/TECHNICAL_QUESTIONS.md b/autobackups/TECHNICAL_QUESTIONS.md new file mode 100644 index 0000000..f9a93f6 --- /dev/null +++ b/autobackups/TECHNICAL_QUESTIONS.md @@ -0,0 +1,124 @@ +# AutoBackups - Preguntas Técnicas Pendientes + +## Preguntas Críticas para el Desarrollo + +### 1. Everything API - Integración +**Pregunta**: ¿Everything está instalado y funcionando en el sistema objetivo? +**Impacto**: Si no está disponible, necesitaremos un método alternativo de búsqueda de archivos. +**Opciones**: +- Usar `python-everything` si Everything está disponible +- Implementar búsqueda recursiva con `os.walk()` como fallback +- Usar `pathlib` con `glob` patterns + +**Decisión necesaria**: ¿Podemos asumir que Everything estará disponible o necesitamos fallback? + +### 2. Estructura de Backup - Clarificación de Rutas +**Pregunta**: Si tenemos un archivo en `C:\Projects\Siemens\LineA\Project1\project.s7p`, ¿el backup debe ser? +**Opción A**: +``` +backup_destination/2025-09-01_14-30-15/Projects_Siemens_LineA_Project1.zip +└── Projects/Siemens/LineA/Project1/... +``` +**Opción B**: +``` +backup_destination/2025-09-01_14-30-15/LineA_Project1.zip +└── LineA/Project1/... +``` + +**Decisión necesaria**: ¿Qué estructura prefieres? + +### 3. Configuración de Schedules - Granularidad +**Pregunta**: ¿Los schedules deben ser configurables a nivel de: +- Por directorio de observación (todos los proyectos de un directorio) +- Por proyecto individual +- Ambos (global con override por proyecto) + +**Decisión necesaria**: ¿Qué nivel de granularidad necesitas? + +### 4. Hash de Archivos - Algoritmo y Almacenamiento +**Pregunta**: Para el sistema de hash en dos etapas: +- ¿Usamos MD5 (rápido) o SHA256 (más seguro) para los hashes? +- ¿El hash incluye solo timestamp o también tamaño de archivo? +- ¿Cómo manejamos archivos que se mueven dentro del proyecto? + +**Decisión necesaria**: ¿Qué balance entre velocidad y precisión prefieres? + +### 5. Detección de Archivos en Uso - Método +**Pregunta**: Para detectar archivos bloqueados, ¿prefieres: +**Opción A**: Intentar abrir cada archivo en modo escritura exclusiva +**Opción B**: Usar `lsof` (Linux) / `handle.exe` (Windows) para listar archivos abiertos +**Opción C**: Solo verificar el archivo .s7p principal + +**Decisión necesaria**: ¿Qué método consideras más apropiado? + +### 6. Manejo de Proyectos Grandes - Performance +**Pregunta**: ¿Hay límites que debamos considerar? +- Tamaño máximo de proyecto para backup +- Tiempo máximo de backup +- Número máximo de archivos por proyecto + +**Decisión necesaria**: ¿Necesitamos algún tipo de throttling o límites? + +### 7. Configuración de Directorios - Recursividad +**Pregunta**: Cuando especificamos un directorio de observación como `C:\Projects`, ¿debemos: +- Buscar solo en subdirectorios inmediatos +- Buscar recursivamente en toda la jerarquía +- Permitir configurar la profundidad de búsqueda + +**Decisión necesaria**: ¿Qué comportamiento prefieres por defecto? + +### 8. Backup Incremental vs Completo +**Pregunta**: ¿Todos los backups deben ser completos o consideras backup incremental? +- Backup completo: Todo el proyecto cada vez +- Backup incremental: Solo archivos modificados + +**Decisión necesaria**: ¿Preferencia por simplicidad o eficiencia de espacio? + +### 9. Web Interface - Características Específicas +**Pregunta**: ¿Qué funcionalidades son prioritarias en la interfaz web? +**Must-have**: +- Lista de proyectos con estado +- Trigger manual de backup +- Ver logs básicos + +**Nice-to-have**: +- Configurar schedules por proyecto +- Ver progreso de backup en tiempo real +- Estadísticas históricas +- Configurar nuevos directorios + +**Decisión necesaria**: ¿Cuáles son must-have vs nice-to-have? + +### 10. Error Handling - Estrategias de Recuperación +**Pregunta**: ¿Cómo manejar errores específicos? +- Espacio insuficiente en destino de backup +- Pérdida de conexión con Everything +- Corrupción de archivos de configuración +- Falla durante compresión + +**Decisión necesaria**: ¿Qué nivel de robustez necesitas? + +### 11. Startup Behavior - Inicialización +**Pregunta**: Al iniciar la aplicación: +- ¿Debe hacer un escaneo completo inmediatamente? +- ¿Debe esperar al primer schedule programado? +- ¿Debe permitir configurar el comportamiento de startup? + +**Decisión necesaria**: ¿Qué comportamiento prefieres al iniciar? + +### 12. Multi-threading - Concurrencia +**Pregunta**: ¿Los backups deben ejecutarse: +- Secuencialmente (uno por vez) +- En paralelo (múltiples simultáneos) +- Con límite configurable de concurrencia + +**Decisión necesaria**: ¿Qué balance entre velocidad y recursos del sistema? + +## Próximos Pasos +Una vez que tengas respuestas a estas preguntas, podremos: +1. Finalizar el diseño técnico +2. Crear el `requirements.txt` definitivo +3. Comenzar con la implementación por fases +4. Definir el plan de testing + +¿Podrías revisar estas preguntas y darme tus preferencias para cada una? diff --git a/autobackups/src/app.py b/autobackups/src/app.py new file mode 100644 index 0000000..f890e2c --- /dev/null +++ b/autobackups/src/app.py @@ -0,0 +1,275 @@ +""" +AutoBackups - Aplicación Principal +Sistema automatizado de backup para proyectos Simatic S7 +""" + +import sys +import os +import logging +from pathlib import Path +from datetime import datetime + +# Agregar el directorio src al path para imports +current_dir = Path(__file__).parent +src_dir = current_dir +sys.path.insert(0, str(src_dir)) + +# Imports de módulos propios +from models.config_model import Config +from models.project_model import ProjectManager +from utils.file_utils import DiskSpaceChecker +from services.basic_backup_service import BasicBackupService + + +class AutoBackupsApp: + """Aplicación principal de AutoBackups""" + + def __init__(self): + self.config = None + self.project_manager = None + self.discovery_service = None + self.disk_checker = None + self.logger = None + + # Inicializar aplicación + self._setup_logging() + self._load_configuration() + self._initialize_services() + + def _setup_logging(self): + """Configurar sistema de logging""" + try: + # Crear directorio de logs si no existe + logs_dir = Path(__file__).parent / ".logs" + logs_dir.mkdir(exist_ok=True) + + # Nombre del archivo de log con timestamp + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_filename = logs_dir / f"autobackups_{timestamp}.log" + + # Configurar logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_filename, encoding='utf-8'), + logging.StreamHandler(sys.stdout) + ] + ) + + self.logger = logging.getLogger(__name__) + self.logger.info(f"AutoBackups iniciado - Log: {log_filename}") + + except Exception as e: + print(f"Error configurando logging: {e}") + sys.exit(1) + + def _load_configuration(self): + """Cargar configuración del sistema""" + try: + self.logger.info("Cargando configuración...") + self.config = Config() + self.logger.info("Configuración cargada exitosamente") + + # Mostrar información básica de configuración + self.logger.info(f"Directorios de observación: {len(self.config.observation_directories)}") + self.logger.info(f"Destino de backups: {self.config.backup_destination}") + + except Exception as e: + self.logger.error(f"Error cargando configuración: {e}") + sys.exit(1) + + def _initialize_services(self): + """Inicializar servicios principales""" + try: + self.logger.info("Inicializando servicios...") + + # Project Manager + self.project_manager = ProjectManager() + self.logger.info("Project Manager inicializado") + + # Disk Space Checker + self.disk_checker = DiskSpaceChecker() + self.logger.info("Disk Space Checker inicializado") + + # Basic Backup Service (Fase 1) + self.backup_service = BasicBackupService(self.config) + self.logger.info("Basic Backup Service inicializado") + + # Project Discovery Service (temporalmente comentado) + # self.discovery_service = ProjectDiscoveryService(self.config, self.project_manager) + # self.logger.info("Project Discovery Service inicializado") + + self.logger.info("Todos los servicios inicializados correctamente") + + except Exception as e: + self.logger.error(f"Error inicializando servicios: {e}") + sys.exit(1) + + def check_system_requirements(self) -> bool: + """Verificar requerimientos del sistema""" + try: + self.logger.info("Verificando requerimientos del sistema...") + + # Verificar espacio en disco + backup_destination = self.config.backup_destination + min_space_mb = self.config.get_min_free_space_mb() + + free_space_mb = self.disk_checker.get_free_space_mb(backup_destination) + + if free_space_mb < min_space_mb: + self.logger.error( + f"Espacio insuficiente en destino de backup. " + f"Disponible: {free_space_mb:.1f}MB, " + f"Requerido: {min_space_mb}MB" + ) + return False + + self.logger.info(f"Espacio en disco OK: {free_space_mb:.1f}MB disponibles") + + # Verificar Everything API (opcional en Fase 1) + if hasattr(self, 'backup_service'): + if self.backup_service.check_system_requirements(): + self.logger.info("Requerimientos del sistema verificados") + else: + self.logger.warning("Algunos requerimientos fallaron") + else: + self.logger.warning("Backup service no disponible") + + # Verificar directorios de observación + missing_dirs = [] + for obs_dir in self.config.observation_directories: + if not Path(obs_dir["path"]).exists(): + missing_dirs.append(obs_dir["path"]) + + if missing_dirs: + self.logger.warning(f"Directorios de observación no encontrados: {missing_dirs}") + + self.logger.info("Verificación de requerimientos completada") + return True + + except Exception as e: + self.logger.error(f"Error verificando requerimientos del sistema: {e}") + return False + + def discover_projects(self) -> int: + """Descubrir proyectos en directorios de observación""" + try: + self.logger.info("Iniciando descubrimiento de proyectos...") + + # Usar el servicio básico de backup + if hasattr(self, 'backup_service'): + projects = self.backup_service.discover_projects_basic() + else: + projects = [] + + # Agregar proyectos al manager + for project_info in projects: + self.project_manager.add_or_update_project(project_info) + + msg = f"Descubrimiento completado: {len(projects)} proyectos" + self.logger.info(msg) + + return len(projects) + + except Exception as e: + self.logger.error(f"Error en descubrimiento de proyectos: {e}") + return 0 + + def show_system_status(self): + """Mostrar estado actual del sistema""" + try: + self.logger.info("=== ESTADO DEL SISTEMA ===") + + # Información de configuración + self.logger.info(f"Directorios de observación configurados: {len(self.config.observation_directories)}") + self.logger.info(f"Destino de backups: {self.config.backup_destination}") + + # Información de proyectos + all_projects = self.project_manager.get_all_projects() + enabled_projects = self.project_manager.get_enabled_projects() + + self.logger.info(f"Total de proyectos: {len(all_projects)}") + self.logger.info(f"Proyectos habilitados: {len(enabled_projects)}") + + # Información de espacio en disco + backup_dest = self.config.backup_destination + total_mb, used_mb, free_mb = self.disk_checker.get_disk_usage_info(backup_dest) + + self.logger.info(f"Espacio en disco (destino backup):") + self.logger.info(f" Total: {total_mb:.1f}MB") + self.logger.info(f" Usado: {used_mb:.1f}MB") + self.logger.info(f" Libre: {free_mb:.1f}MB") + + # Información de Everything API + if self.discovery_service.everything_searcher: + self.logger.info("Everything API: Disponible") + else: + self.logger.info("Everything API: No disponible") + + self.logger.info("=== FIN ESTADO DEL SISTEMA ===") + + except Exception as e: + self.logger.error(f"Error mostrando estado del sistema: {e}") + + def run_initial_setup(self): + """Ejecutar configuración inicial de la aplicación""" + try: + self.logger.info("=== CONFIGURACIÓN INICIAL DE AUTOBACKUPS ===") + + # Verificar requerimientos del sistema + if not self.check_system_requirements(): + self.logger.error("Los requerimientos del sistema no se cumplen") + return False + + # Descubrir proyectos + projects_found = self.discover_projects() + + if projects_found == 0: + self.logger.warning("No se encontraron proyectos para backup") + + # Mostrar estado del sistema + self.show_system_status() + + self.logger.info("=== CONFIGURACIÓN INICIAL COMPLETADA ===") + return True + + except Exception as e: + self.logger.error(f"Error en configuración inicial: {e}") + return False + + +def main(): + """Función principal""" + try: + print("AutoBackups - Sistema de Backup Automatizado") + print("=" * 50) + + # Crear y configurar aplicación + app = AutoBackupsApp() + + # Ejecutar configuración inicial + if app.run_initial_setup(): + app.logger.info("AutoBackups configurado correctamente") + + # TODO: En las siguientes fases: + # - Iniciar scheduler de backups + # - Iniciar interfaz web Flask + # - Configurar tareas en background + + print("\nConfiguración inicial completada.") + print("Revisa el archivo de log para más detalles.") + + else: + print("Error en la configuración inicial. Revisa los logs.") + sys.exit(1) + + except KeyboardInterrupt: + print("\nAplicación interrumpida por el usuario") + except Exception as e: + print(f"Error inesperado: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/autobackups/src/models/__init__.py b/autobackups/src/models/__init__.py new file mode 100644 index 0000000..695ef75 --- /dev/null +++ b/autobackups/src/models/__init__.py @@ -0,0 +1,9 @@ +""" +AutoBackups - Data Models +Modelos de datos para configuración y manejo de proyectos +""" + +from .config_model import Config +from .project_model import Project, ProjectStatus + +__all__ = ['Config', 'Project', 'ProjectStatus'] diff --git a/autobackups/src/models/config_model.py b/autobackups/src/models/config_model.py new file mode 100644 index 0000000..b63a4ef --- /dev/null +++ b/autobackups/src/models/config_model.py @@ -0,0 +1,167 @@ +""" +Configuration Model +Manejo de la configuración global del sistema +""" + +import json +import os +from typing import Dict, List, Any +from pathlib import Path + + +class Config: + """Clase para manejar la configuración global del sistema""" + + def __init__(self, config_path: str = None): + if config_path is None: + # Buscar config.json en el directorio del proyecto + current_dir = Path(__file__).parent.parent.parent + config_path = current_dir / "config.json" + + self.config_path = Path(config_path) + self._config = {} + self.load_config() + + def load_config(self) -> None: + """Cargar configuración desde archivo JSON""" + try: + if self.config_path.exists(): + with open(self.config_path, 'r', encoding='utf-8') as f: + self._config = json.load(f) + else: + # Crear configuración por defecto si no existe + self._create_default_config() + self.save_config() + except Exception as e: + raise Exception(f"Error cargando configuración: {e}") + + def save_config(self) -> None: + """Guardar configuración actual al archivo JSON""" + try: + with open(self.config_path, 'w', encoding='utf-8') as f: + json.dump(self._config, f, indent=2, ensure_ascii=False) + except Exception as e: + raise Exception(f"Error guardando configuración: {e}") + + def _create_default_config(self) -> None: + """Crear configuración por defecto""" + self._config = { + "observation_directories": [], + "backup_destination": "D:\\Backups\\AutoBackups", + "global_settings": { + "default_schedule": "daily", + "default_schedule_time": "02:00", + "retry_delay_hours": 1, + "backup_timeout_minutes": 0, + "min_free_space_mb": 100, + "scan_interval_minutes": 60, + "min_backup_interval_minutes": 10 + }, + "everything_api": { + "dll_path": "Everything-SDK\\dll\\Everything64.dll", + "enabled": True, + "search_depth": -1, + "skip_last_level_for_s7p": True + }, + "web_interface": { + "host": "127.0.0.1", + "port": 5000, + "debug": False + }, + "logging": { + "level": "INFO", + "max_log_files": 30, + "log_rotation_days": 30 + }, + "backup_options": { + "compression_level": 6, + "include_subdirectories": True, + "preserve_directory_structure": True, + "hash_algorithm": "md5", + "hash_includes": ["timestamp", "size"], + "backup_type": "complete", + "process_priority": "low", + "sequential_execution": True, + "filename_format": "HH-MM-SS_projects.zip", + "date_format": "YYYY-MM-DD" + } + } + + # Getters para las secciones principales + @property + def observation_directories(self) -> List[Dict[str, Any]]: + return self._config.get("observation_directories", []) + + @property + def backup_destination(self) -> str: + return self._config.get("backup_destination", "") + + @property + def global_settings(self) -> Dict[str, Any]: + return self._config.get("global_settings", {}) + + @property + def everything_api(self) -> Dict[str, Any]: + return self._config.get("everything_api", {}) + + @property + def web_interface(self) -> Dict[str, Any]: + return self._config.get("web_interface", {}) + + @property + def logging_config(self) -> Dict[str, Any]: + return self._config.get("logging", {}) + + @property + def backup_options(self) -> Dict[str, Any]: + return self._config.get("backup_options", {}) + + # Métodos de utilidad + def get_dll_path(self) -> str: + """Obtener la ruta completa de la DLL de Everything""" + dll_relative_path = self.everything_api.get("dll_path", "") + if dll_relative_path: + # Convertir ruta relativa a absoluta + current_dir = Path(__file__).parent.parent.parent.parent + return str(current_dir / dll_relative_path) + return "" + + def get_min_free_space_mb(self) -> int: + """Obtener el espacio mínimo libre requerido en MB""" + return self.global_settings.get("min_free_space_mb", 100) + + def get_scan_interval_minutes(self) -> int: + """Obtener el intervalo de escaneo en minutos""" + return self.global_settings.get("scan_interval_minutes", 60) + + def get_min_backup_interval_minutes(self) -> int: + """Obtener el intervalo mínimo de backup en minutos""" + return self.global_settings.get("min_backup_interval_minutes", 10) + + def add_observation_directory(self, path: str, dir_type: str, description: str = "") -> None: + """Agregar un nuevo directorio de observación""" + new_dir = { + "path": path, + "type": dir_type, + "enabled": True, + "description": description + } + if "observation_directories" not in self._config: + self._config["observation_directories"] = [] + self._config["observation_directories"].append(new_dir) + self.save_config() + + def update_config_value(self, key_path: str, value: Any) -> None: + """Actualizar un valor específico en la configuración usando dot notation""" + keys = key_path.split('.') + current = self._config + + # Navegar hasta el penúltimo nivel + for key in keys[:-1]: + if key not in current: + current[key] = {} + current = current[key] + + # Establecer el valor final + current[keys[-1]] = value + self.save_config() diff --git a/autobackups/src/models/project_model.py b/autobackups/src/models/project_model.py new file mode 100644 index 0000000..efdec11 --- /dev/null +++ b/autobackups/src/models/project_model.py @@ -0,0 +1,265 @@ +""" +Project Model +Modelo de datos para manejo de proyectos y su estado +""" + +import json +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Dict, List, Any, Optional + + +class ProjectStatus(Enum): + """Estados posibles de un proyecto""" + READY = "ready" + BACKING_UP = "backing_up" + ERROR = "error" + FILES_IN_USE = "files_in_use" + RETRY_PENDING = "retry_pending" + DISABLED = "disabled" + + +class Project: + """Clase para representar un proyecto individual""" + + def __init__(self, project_data: Dict[str, Any]): + self.id = project_data.get("id", "") + self.name = project_data.get("name", "") + self.path = project_data.get("path", "") + self.type = project_data.get("type", "") + self.s7p_file = project_data.get("s7p_file", "") + self.observation_directory = project_data.get("observation_directory", "") + self.relative_path = project_data.get("relative_path", "") + self.backup_path = project_data.get("backup_path", "") + + # Configuración de schedule + schedule_config = project_data.get("schedule_config", {}) + self.schedule = schedule_config.get("schedule", "daily") + self.schedule_time = schedule_config.get("schedule_time", "02:00") + self.enabled = schedule_config.get("enabled", True) + self.next_scheduled_backup = schedule_config.get("next_scheduled_backup", "") + + # Historia de backups + backup_history = project_data.get("backup_history", {}) + self.last_backup_date = backup_history.get("last_backup_date", "") + self.last_backup_file = backup_history.get("last_backup_file", "") + self.backup_count = backup_history.get("backup_count", 0) + self.last_successful_backup = backup_history.get("last_successful_backup", "") + + # Información de hash + hash_info = project_data.get("hash_info", {}) + self.last_s7p_hash = hash_info.get("last_s7p_hash", "") + self.last_full_hash = hash_info.get("last_full_hash", "") + self.last_s7p_timestamp = hash_info.get("last_s7p_timestamp", "") + self.last_s7p_size = hash_info.get("last_s7p_size", 0) + self.last_scan_timestamp = hash_info.get("last_scan_timestamp", "") + self.file_count = hash_info.get("file_count", 0) + self.total_size_bytes = hash_info.get("total_size_bytes", 0) + + # Estado actual + status = project_data.get("status", {}) + self.current_status = ProjectStatus(status.get("current_status", "ready")) + self.last_error = status.get("last_error", None) + self.retry_count = status.get("retry_count", 0) + self.next_retry = status.get("next_retry", None) + self.files_in_use = status.get("files_in_use", False) + self.exclusivity_check_passed = status.get("exclusivity_check_passed", True) + self.last_status_update = status.get("last_status_update", "") + + # Información de descubrimiento + discovery_info = project_data.get("discovery_info", {}) + self.discovered_date = discovery_info.get("discovered_date", "") + self.discovery_method = discovery_info.get("discovery_method", "") + self.auto_discovered = discovery_info.get("auto_discovered", True) + + def to_dict(self) -> Dict[str, Any]: + """Convertir el proyecto a diccionario para serialización JSON""" + return { + "id": self.id, + "name": self.name, + "path": self.path, + "type": self.type, + "s7p_file": self.s7p_file, + "observation_directory": self.observation_directory, + "relative_path": self.relative_path, + "backup_path": self.backup_path, + "schedule_config": { + "schedule": self.schedule, + "schedule_time": self.schedule_time, + "enabled": self.enabled, + "next_scheduled_backup": self.next_scheduled_backup + }, + "backup_history": { + "last_backup_date": self.last_backup_date, + "last_backup_file": self.last_backup_file, + "backup_count": self.backup_count, + "last_successful_backup": self.last_successful_backup + }, + "hash_info": { + "last_s7p_hash": self.last_s7p_hash, + "last_full_hash": self.last_full_hash, + "last_s7p_timestamp": self.last_s7p_timestamp, + "last_s7p_size": self.last_s7p_size, + "last_scan_timestamp": self.last_scan_timestamp, + "file_count": self.file_count, + "total_size_bytes": self.total_size_bytes + }, + "status": { + "current_status": self.current_status.value, + "last_error": self.last_error, + "retry_count": self.retry_count, + "next_retry": self.next_retry, + "files_in_use": self.files_in_use, + "exclusivity_check_passed": self.exclusivity_check_passed, + "last_status_update": self.last_status_update + }, + "discovery_info": { + "discovered_date": self.discovered_date, + "discovery_method": self.discovery_method, + "auto_discovered": self.auto_discovered + } + } + + def update_status(self, status: ProjectStatus, error_message: str = None) -> None: + """Actualizar el estado del proyecto""" + self.current_status = status + self.last_error = error_message + self.last_status_update = datetime.now(timezone.utc).isoformat() + + if status == ProjectStatus.ERROR: + self.retry_count += 1 + elif status == ProjectStatus.READY: + self.retry_count = 0 + self.last_error = None + + def update_hash_info(self, s7p_hash: str = None, full_hash: str = None, + s7p_timestamp: str = None, s7p_size: int = None, + file_count: int = None, total_size: int = None) -> None: + """Actualizar información de hash""" + if s7p_hash is not None: + self.last_s7p_hash = s7p_hash + if full_hash is not None: + self.last_full_hash = full_hash + if s7p_timestamp is not None: + self.last_s7p_timestamp = s7p_timestamp + if s7p_size is not None: + self.last_s7p_size = s7p_size + if file_count is not None: + self.file_count = file_count + if total_size is not None: + self.total_size_bytes = total_size + + self.last_scan_timestamp = datetime.now(timezone.utc).isoformat() + + def update_backup_info(self, backup_file_path: str) -> None: + """Actualizar información después de un backup exitoso""" + now = datetime.now(timezone.utc).isoformat() + self.last_backup_date = now + self.last_successful_backup = now + self.last_backup_file = backup_file_path + self.backup_count += 1 + self.update_status(ProjectStatus.READY) + + +class ProjectManager: + """Clase para manejar la colección de proyectos""" + + def __init__(self, projects_file_path: str = None): + if projects_file_path is None: + # Buscar projects.json en el directorio del proyecto + current_dir = Path(__file__).parent.parent.parent + projects_file_path = current_dir / "projects.json" + + self.projects_file_path = Path(projects_file_path) + self.projects: Dict[str, Project] = {} + self.metadata = {} + self.statistics = {} + self.load_projects() + + def load_projects(self) -> None: + """Cargar proyectos desde archivo JSON""" + try: + if self.projects_file_path.exists(): + with open(self.projects_file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + self.metadata = data.get("metadata", {}) + self.statistics = data.get("statistics", {}) + + # Cargar proyectos + for project_data in data.get("projects", []): + project = Project(project_data) + self.projects[project.id] = project + else: + # Crear archivo por defecto si no existe + self._create_default_projects_file() + self.save_projects() + except Exception as e: + raise Exception(f"Error cargando proyectos: {e}") + + def save_projects(self) -> None: + """Guardar proyectos al archivo JSON""" + try: + data = { + "metadata": self.metadata, + "projects": [project.to_dict() for project in self.projects.values()], + "statistics": self.statistics + } + + with open(self.projects_file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except Exception as e: + raise Exception(f"Error guardando proyectos: {e}") + + def _create_default_projects_file(self) -> None: + """Crear archivo de proyectos por defecto""" + self.metadata = { + "version": "1.0", + "last_updated": datetime.now(timezone.utc).isoformat(), + "total_projects": 0 + } + + self.statistics = { + "total_backups_created": 0, + "total_backup_size_mb": 0.0, + "average_backup_time_seconds": 0.0, + "last_global_scan": "", + "projects_with_errors": 0, + "projects_pending_retry": 0 + } + + def add_project(self, project_data: Dict[str, Any]) -> Project: + """Agregar un nuevo proyecto""" + project = Project(project_data) + self.projects[project.id] = project + self.metadata["total_projects"] = len(self.projects) + self.metadata["last_updated"] = datetime.now(timezone.utc).isoformat() + self.save_projects() + return project + + def get_project(self, project_id: str) -> Optional[Project]: + """Obtener un proyecto por ID""" + return self.projects.get(project_id) + + def get_all_projects(self) -> List[Project]: + """Obtener todos los proyectos""" + return list(self.projects.values()) + + def get_projects_by_status(self, status: ProjectStatus) -> List[Project]: + """Obtener proyectos por estado""" + return [p for p in self.projects.values() if p.current_status == status] + + def get_enabled_projects(self) -> List[Project]: + """Obtener proyectos habilitados""" + return [p for p in self.projects.values() if p.enabled] + + def update_statistics(self) -> None: + """Actualizar estadísticas generales""" + self.statistics["projects_with_errors"] = len( + self.get_projects_by_status(ProjectStatus.ERROR) + ) + self.statistics["projects_pending_retry"] = len( + self.get_projects_by_status(ProjectStatus.RETRY_PENDING) + ) + self.save_projects() diff --git a/autobackups/src/services/__init__.py b/autobackups/src/services/__init__.py new file mode 100644 index 0000000..cd57328 --- /dev/null +++ b/autobackups/src/services/__init__.py @@ -0,0 +1,12 @@ +""" +AutoBackups - Services +Servicios principales para backup y descubrimiento de proyectos +""" + +# Imports comentados temporalmente para evitar problemas de dependencias circulares +# from .project_discovery_service import ProjectDiscoveryService +# from .backup_service import BackupService +# from .scheduler_service import SchedulerService +from .basic_backup_service import BasicBackupService + +__all__ = ['BasicBackupService'] diff --git a/autobackups/src/services/basic_backup_service.py b/autobackups/src/services/basic_backup_service.py new file mode 100644 index 0000000..4762408 --- /dev/null +++ b/autobackups/src/services/basic_backup_service.py @@ -0,0 +1,94 @@ +""" +Servicio de Backup Básico +Versión simplificada para Fase 1 sin Everything API +""" + +import os +import logging +from pathlib import Path +from typing import List, Dict, Any + + +class BasicBackupService: + """Servicio básico de backup sin Everything API""" + + def __init__(self, config): + self.config = config + self.logger = logging.getLogger(__name__) + + def check_system_requirements(self) -> bool: + """Verificar requerimientos básicos del sistema""" + try: + # Verificar directorio de backup + backup_dest = self.config.backup_destination + if not os.path.exists(backup_dest): + self.logger.error(f"Directorio de backup no existe: {backup_dest}") + return False + + # Verificar directorios de observación + obs_dirs = self.config.observation_directories + for obs_dir in obs_dirs: + if obs_dir.get("enabled", True): + path = obs_dir["path"] + if not os.path.exists(path): + self.logger.warning(f"Directorio de observación no existe: {path}") + + self.logger.info("Requerimientos básicos del sistema verificados") + return True + + except Exception as e: + self.logger.error(f"Error verificando requerimientos: {e}") + return False + + def discover_projects_basic(self) -> List[Dict[str, Any]]: + """Descubrimiento básico de proyectos usando búsqueda de archivos""" + projects = [] + + try: + obs_dirs = [ + obs_dir for obs_dir in self.config.observation_directories + if obs_dir.get("enabled", True) and obs_dir.get("type") == "siemens_s7" + ] + + for obs_dir in obs_dirs: + self.logger.info(f"Buscando proyectos S7 en: {obs_dir['path']}") + + # Buscar archivos .s7p recursivamente + base_path = Path(obs_dir["path"]) + if base_path.exists(): + s7p_files = list(base_path.rglob("*.s7p")) + + for s7p_file in s7p_files: + project_info = self._create_project_info(s7p_file, obs_dir) + projects.append(project_info) + self.logger.info(f"Proyecto encontrado: {project_info['name']}") + + self.logger.info(f"Descubrimiento completado. {len(projects)} proyectos encontrados") + return projects + + except Exception as e: + self.logger.error(f"Error en descubrimiento de proyectos: {e}") + return [] + + def _create_project_info(self, s7p_file: Path, obs_dir: Dict[str, Any]) -> Dict[str, Any]: + """Crear información de proyecto desde archivo .s7p""" + project_path = s7p_file.parent + obs_path = Path(obs_dir["path"]) + + # Calcular ruta relativa + try: + relative_path = project_path.relative_to(obs_path) + except ValueError: + relative_path = project_path.name + + return { + "name": project_path.name, + "path": str(project_path), + "s7p_file": str(s7p_file), + "observation_directory": obs_dir["path"], + "relative_path": str(relative_path), + "backup_path": str(relative_path), + "type": "siemens_s7", + "auto_discovered": True, + "discovery_method": "filesystem_search" + } diff --git a/autobackups/src/services/project_discovery_service.py b/autobackups/src/services/project_discovery_service.py new file mode 100644 index 0000000..2c7d470 --- /dev/null +++ b/autobackups/src/services/project_discovery_service.py @@ -0,0 +1,325 @@ +""" +Project Discovery Service +Servicio para descubrir proyectos S7 usando Everything API +""" + +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Dict, Any, Optional +import logging + +from ..models.project_model import Project, ProjectManager +from ..models.config_model import Config +from ..utils.everything_wrapper import EverythingSearcher, create_everything_searcher + + +class ProjectDiscoveryService: + """Servicio para descubrir y gestionar proyectos""" + + def __init__(self, config: Config, project_manager: ProjectManager): + self.config = config + self.project_manager = project_manager + self.logger = logging.getLogger(__name__) + + # Inicializar Everything searcher + dll_path = config.get_dll_path() + self.everything_searcher = create_everything_searcher(dll_path) + + if not self.everything_searcher: + self.logger.warning("Everything API no disponible, funcionalidad limitada") + + def discover_all_projects(self) -> List[Project]: + """ + Descubrir todos los proyectos en los directorios de observación + """ + self.logger.info("Iniciando descubrimiento de proyectos...") + + discovered_projects = [] + + # Obtener directorios de observación habilitados + observation_dirs = [ + obs_dir for obs_dir in self.config.observation_directories + if obs_dir.get("enabled", True) + ] + + for obs_dir in observation_dirs: + dir_path = obs_dir["path"] + dir_type = obs_dir["type"] + + self.logger.info(f"Escaneando directorio: {dir_path} (tipo: {dir_type})") + + if dir_type == "siemens_s7": + projects = self._discover_s7_projects(obs_dir) + else: # manual directories + projects = self._discover_manual_projects(obs_dir) + + discovered_projects.extend(projects) + + self.logger.info(f"Descubrimiento completado: {len(discovered_projects)} proyectos encontrados") + + # Actualizar project manager con proyectos descubiertos + self._update_project_manager(discovered_projects) + + return discovered_projects + + def _discover_s7_projects(self, obs_dir: Dict[str, Any]) -> List[Project]: + """Descubrir proyectos S7 en un directorio de observación""" + projects = [] + dir_path = obs_dir["path"] + + try: + if self.everything_searcher and self.everything_searcher.is_everything_available(): + # Usar Everything API para búsqueda rápida + s7p_files = self.everything_searcher.search_s7p_files([dir_path]) + else: + # Fallback a búsqueda manual + s7p_files = self._manual_search_s7p_files(dir_path) + + self.logger.debug(f"Encontrados {len(s7p_files)} archivos .s7p en {dir_path}") + + for s7p_file in s7p_files: + project = self._create_project_from_s7p(s7p_file, obs_dir) + if project: + projects.append(project) + self.logger.debug(f"Proyecto creado: {project.name}") + + except Exception as e: + self.logger.error(f"Error descubriendo proyectos S7 en {dir_path}: {e}") + + return projects + + def _discover_manual_projects(self, obs_dir: Dict[str, Any]) -> List[Project]: + """Descubrir proyectos de directorio manual""" + projects = [] + dir_path = obs_dir["path"] + + try: + # Para directorios manuales, crear un proyecto por directorio + if Path(dir_path).exists() and Path(dir_path).is_dir(): + project = self._create_manual_project(obs_dir) + if project: + projects.append(project) + self.logger.debug(f"Proyecto manual creado: {project.name}") + + except Exception as e: + self.logger.error(f"Error creando proyecto manual para {dir_path}: {e}") + + return projects + + def _manual_search_s7p_files(self, directory: str) -> List[str]: + """Búsqueda manual de archivos .s7p como fallback""" + s7p_files = [] + + try: + path = Path(directory) + if not path.exists(): + return s7p_files + + # Búsqueda recursiva con optimización (evitar último nivel) + skip_last_level = self.config.everything_api.get("skip_last_level_for_s7p", True) + + for file_path in path.rglob("*.s7p"): + if skip_last_level: + # Verificar que no esté en el último nivel (debe tener subdirectorios) + parent_dir = file_path.parent + has_subdirs = any(item.is_dir() for item in parent_dir.iterdir()) + if not has_subdirs: + continue # Saltar archivos en directorios sin subdirectorios + + s7p_files.append(str(file_path)) + + except Exception as e: + self.logger.error(f"Error en búsqueda manual de S7P en {directory}: {e}") + + return s7p_files + + def _create_project_from_s7p(self, s7p_file_path: str, obs_dir: Dict[str, Any]) -> Optional[Project]: + """Crear un objeto Project desde un archivo .s7p""" + try: + s7p_path = Path(s7p_file_path) + project_dir = s7p_path.parent + obs_path = Path(obs_dir["path"]) + + # Generar ID único para el proyecto + project_id = self._generate_project_id(s7p_file_path) + + # Determinar nombre del proyecto + project_name = s7p_path.stem + + # Calcular ruta relativa + try: + relative_path = project_dir.relative_to(obs_path) + backup_path = str(relative_path) + except ValueError: + # Si no se puede calcular relativa, usar nombre del directorio + backup_path = project_dir.name + relative_path = Path(project_dir.name) + + # Crear datos del proyecto + project_data = { + "id": project_id, + "name": project_name, + "path": str(project_dir), + "type": "siemens_s7", + "s7p_file": s7p_file_path, + "observation_directory": obs_dir["path"], + "relative_path": str(relative_path), + "backup_path": backup_path, + "schedule_config": { + "schedule": self.config.global_settings.get("default_schedule", "daily"), + "schedule_time": self.config.global_settings.get("default_schedule_time", "02:00"), + "enabled": True, + "next_scheduled_backup": "" + }, + "backup_history": { + "last_backup_date": "", + "last_backup_file": "", + "backup_count": 0, + "last_successful_backup": "" + }, + "hash_info": { + "last_s7p_hash": "", + "last_full_hash": "", + "last_s7p_timestamp": "", + "last_s7p_size": 0, + "last_scan_timestamp": "", + "file_count": 0, + "total_size_bytes": 0 + }, + "status": { + "current_status": "ready", + "last_error": None, + "retry_count": 0, + "next_retry": None, + "files_in_use": False, + "exclusivity_check_passed": True, + "last_status_update": datetime.now(timezone.utc).isoformat() + }, + "discovery_info": { + "discovered_date": datetime.now(timezone.utc).isoformat(), + "discovery_method": "everything_api" if self.everything_searcher else "manual_search", + "auto_discovered": True + } + } + + return Project(project_data) + + except Exception as e: + self.logger.error(f"Error creando proyecto desde {s7p_file_path}: {e}") + return None + + def _create_manual_project(self, obs_dir: Dict[str, Any]) -> Optional[Project]: + """Crear un proyecto para directorio manual""" + try: + dir_path = Path(obs_dir["path"]) + + # Generar ID único + project_id = self._generate_project_id(str(dir_path)) + + # Usar nombre del directorio como nombre del proyecto + project_name = dir_path.name + + project_data = { + "id": project_id, + "name": project_name, + "path": str(dir_path), + "type": "manual", + "s7p_file": "", + "observation_directory": str(dir_path.parent), + "relative_path": dir_path.name, + "backup_path": dir_path.name, + "schedule_config": { + "schedule": self.config.global_settings.get("default_schedule", "daily"), + "schedule_time": self.config.global_settings.get("default_schedule_time", "02:00"), + "enabled": True, + "next_scheduled_backup": "" + }, + "backup_history": { + "last_backup_date": "", + "last_backup_file": "", + "backup_count": 0, + "last_successful_backup": "" + }, + "hash_info": { + "last_s7p_hash": "", + "last_full_hash": "", + "last_s7p_timestamp": "", + "last_s7p_size": 0, + "last_scan_timestamp": "", + "file_count": 0, + "total_size_bytes": 0 + }, + "status": { + "current_status": "ready", + "last_error": None, + "retry_count": 0, + "next_retry": None, + "files_in_use": False, + "exclusivity_check_passed": True, + "last_status_update": datetime.now(timezone.utc).isoformat() + }, + "discovery_info": { + "discovered_date": datetime.now(timezone.utc).isoformat(), + "discovery_method": "manual_directory", + "auto_discovered": True + } + } + + return Project(project_data) + + except Exception as e: + self.logger.error(f"Error creando proyecto manual para {obs_dir['path']}: {e}") + return None + + def _generate_project_id(self, path: str) -> str: + """Generar ID único para un proyecto basado en su ruta""" + # Usar hash de la ruta + timestamp para garantizar unicidad + import hashlib + path_hash = hashlib.md5(path.encode()).hexdigest()[:8] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"project_{path_hash}_{timestamp}" + + def _update_project_manager(self, discovered_projects: List[Project]) -> None: + """Actualizar el project manager con proyectos descubiertos""" + try: + # Obtener proyectos existentes + existing_projects = {p.path: p for p in self.project_manager.get_all_projects()} + + # Agregar nuevos proyectos + new_projects_count = 0 + for project in discovered_projects: + if project.path not in existing_projects: + self.project_manager.add_project(project.to_dict()) + new_projects_count += 1 + self.logger.debug(f"Proyecto agregado: {project.name}") + + if new_projects_count > 0: + self.logger.info(f"Se agregaron {new_projects_count} nuevos proyectos") + else: + self.logger.info("No se encontraron nuevos proyectos") + + # Actualizar estadísticas + self.project_manager.update_statistics() + + except Exception as e: + self.logger.error(f"Error actualizando project manager: {e}") + + def rescan_observation_directories(self) -> int: + """ + Re-escanear directorios de observación para nuevos proyectos + Retorna el número de nuevos proyectos encontrados + """ + self.logger.info("Iniciando re-escaneo de directorios de observación...") + + initial_count = len(self.project_manager.get_all_projects()) + + # Re-descubrir proyectos + self.discover_all_projects() + + final_count = len(self.project_manager.get_all_projects()) + new_projects = final_count - initial_count + + self.logger.info(f"Re-escaneo completado: {new_projects} nuevos proyectos encontrados") + + return new_projects diff --git a/autobackups/src/utils/__init__.py b/autobackups/src/utils/__init__.py new file mode 100644 index 0000000..5af83ed --- /dev/null +++ b/autobackups/src/utils/__init__.py @@ -0,0 +1,10 @@ +""" +AutoBackups - Utilities +Funciones de utilidad para hashing, manejo de archivos y otras operaciones +""" + +from .hash_utils import HashCalculator +from .file_utils import FileChecker, DiskSpaceChecker +from .everything_wrapper import EverythingSearcher + +__all__ = ['HashCalculator', 'FileChecker', 'DiskSpaceChecker', 'EverythingSearcher'] diff --git a/autobackups/src/utils/everything_wrapper.py b/autobackups/src/utils/everything_wrapper.py new file mode 100644 index 0000000..fe71cd1 --- /dev/null +++ b/autobackups/src/utils/everything_wrapper.py @@ -0,0 +1,272 @@ +""" +Everything API Wrapper +Integración con Everything para búsqueda rápida de archivos .s7p +""" + +import os +import ctypes +from pathlib import Path +from typing import List, Optional, Tuple +import logging + + +class EverythingSearcher: + """Wrapper para la API de Everything""" + + def __init__(self, dll_path: str = None): + self.logger = logging.getLogger(__name__) + self.dll = None + self.is_initialized = False + + if dll_path: + self.load_dll(dll_path) + + def load_dll(self, dll_path: str) -> bool: + """Cargar la DLL de Everything""" + try: + if not os.path.exists(dll_path): + self.logger.error(f"DLL de Everything no encontrada: {dll_path}") + return False + + self.dll = ctypes.WinDLL(dll_path) + + # Definir funciones de la API + self._setup_api_functions() + + # Inicializar Everything + self.is_initialized = self._initialize_everything() + + if self.is_initialized: + self.logger.info("Everything API inicializada correctamente") + else: + self.logger.error("Error inicializando Everything API") + + return self.is_initialized + + except Exception as e: + self.logger.error(f"Error cargando DLL de Everything: {e}") + return False + + def _setup_api_functions(self): + """Configurar las funciones de la API""" + try: + # Everything_SetSearchW - Establecer consulta de búsqueda + self.dll.Everything_SetSearchW.argtypes = [ctypes.c_wchar_p] + self.dll.Everything_SetSearchW.restype = None + + # Everything_SetMatchPath - Habilitar coincidencia de ruta completa + self.dll.Everything_SetMatchPath.argtypes = [ctypes.c_bool] + self.dll.Everything_SetMatchPath.restype = None + + # Everything_SetMatchCase - Habilitar coincidencia de mayúsculas/minúsculas + self.dll.Everything_SetMatchCase.argtypes = [ctypes.c_bool] + self.dll.Everything_SetMatchCase.restype = None + + # Everything_QueryW - Ejecutar consulta + self.dll.Everything_QueryW.argtypes = [ctypes.c_bool] + self.dll.Everything_QueryW.restype = ctypes.c_bool + + # Everything_GetNumResults - Obtener número de resultados + self.dll.Everything_GetNumResults.restype = ctypes.c_uint + + # Everything_GetResultFullPathNameW - Obtener ruta completa del resultado + self.dll.Everything_GetResultFullPathNameW.argtypes = [ + ctypes.c_uint, ctypes.c_wchar_p, ctypes.c_uint + ] + self.dll.Everything_GetResultFullPathNameW.restype = ctypes.c_uint + + # Everything_GetLastError - Obtener último error + self.dll.Everything_GetLastError.restype = ctypes.c_uint + + self.logger.debug("Funciones API configuradas correctamente") + + except Exception as e: + self.logger.error(f"Error configurando funciones API: {e}") + raise + + def _initialize_everything(self) -> bool: + """Inicializar Everything con configuraciones por defecto""" + try: + # Configurar opciones de búsqueda + self.dll.Everything_SetMatchPath(True) + self.dll.Everything_SetMatchCase(False) + + return True + + except Exception as e: + self.logger.error(f"Error inicializando Everything: {e}") + return False + + def search_s7p_files(self, search_directories: List[str]) -> List[str]: + """ + Buscar archivos .s7p en los directorios especificados + Retorna lista de rutas completas a archivos .s7p encontrados + """ + if not self.is_initialized: + self.logger.error("Everything no está inicializado") + return [] + + try: + all_s7p_files = [] + + for directory in search_directories: + s7p_files = self._search_s7p_in_directory(directory) + all_s7p_files.extend(s7p_files) + + # Eliminar duplicados manteniendo el orden + unique_files = [] + seen = set() + for file_path in all_s7p_files: + if file_path not in seen: + seen.add(file_path) + unique_files.append(file_path) + + self.logger.info(f"Encontrados {len(unique_files)} archivos .s7p únicos") + return unique_files + + except Exception as e: + self.logger.error(f"Error buscando archivos S7P: {e}") + return [] + + def _search_s7p_in_directory(self, directory: str) -> List[str]: + """Buscar archivos .s7p en un directorio específico""" + try: + # Normalizar la ruta del directorio + directory = os.path.normpath(directory) + + # Construir consulta de búsqueda + # Buscar archivos .s7p en el directorio y subdirectorios + search_query = f'"{directory}" ext:s7p' + + self.logger.debug(f"Búsqueda en Everything: {search_query}") + + # Establecer consulta + self.dll.Everything_SetSearchW(search_query) + + # Ejecutar búsqueda + if not self.dll.Everything_QueryW(True): + error_code = self.dll.Everything_GetLastError() + self.logger.error(f"Error en consulta Everything: código {error_code}") + return [] + + # Obtener número de resultados + num_results = self.dll.Everything_GetNumResults() + self.logger.debug(f"Everything encontró {num_results} resultados para {directory}") + + if num_results == 0: + return [] + + # Extraer rutas de los resultados + results = [] + buffer_size = 4096 # Tamaño del buffer para rutas + + for i in range(num_results): + # Buffer para almacenar la ruta + path_buffer = ctypes.create_unicode_buffer(buffer_size) + + # Obtener ruta completa + chars_copied = self.dll.Everything_GetResultFullPathNameW( + i, path_buffer, buffer_size + ) + + if chars_copied > 0: + file_path = path_buffer.value + if file_path and os.path.exists(file_path): + results.append(file_path) + self.logger.debug(f"Archivo S7P encontrado: {file_path}") + + return results + + except Exception as e: + self.logger.error(f"Error buscando en directorio {directory}: {e}") + return [] + + def search_files_by_pattern(self, pattern: str, max_results: int = 1000) -> List[str]: + """ + Buscar archivos usando un patrón específico + """ + if not self.is_initialized: + self.logger.error("Everything no está inicializado") + return [] + + try: + self.logger.debug(f"Búsqueda por patrón: {pattern}") + + # Establecer consulta + self.dll.Everything_SetSearchW(pattern) + + # Ejecutar búsqueda + if not self.dll.Everything_QueryW(True): + error_code = self.dll.Everything_GetLastError() + self.logger.error(f"Error en consulta Everything: código {error_code}") + return [] + + # Obtener número de resultados + num_results = self.dll.Everything_GetNumResults() + actual_results = min(num_results, max_results) + + self.logger.debug(f"Procesando {actual_results} de {num_results} resultados") + + results = [] + buffer_size = 4096 + + for i in range(actual_results): + path_buffer = ctypes.create_unicode_buffer(buffer_size) + chars_copied = self.dll.Everything_GetResultFullPathNameW( + i, path_buffer, buffer_size + ) + + if chars_copied > 0: + file_path = path_buffer.value + if file_path and os.path.exists(file_path): + results.append(file_path) + + return results + + except Exception as e: + self.logger.error(f"Error buscando patrón {pattern}: {e}") + return [] + + def is_everything_available(self) -> bool: + """Verificar si Everything está disponible y funcionando""" + return self.is_initialized + + def get_project_directory_from_s7p(self, s7p_file_path: str, + observation_directory: str) -> Tuple[str, str]: + """ + Determinar el directorio del proyecto y la ruta relativa desde un archivo .s7p + Retorna: (directorio_proyecto, ruta_relativa) + """ + try: + s7p_path = Path(s7p_file_path) + obs_path = Path(observation_directory) + + # El directorio del proyecto es el padre del archivo .s7p + project_dir = s7p_path.parent + + # Calcular ruta relativa desde el directorio de observación + try: + relative_path = project_dir.relative_to(obs_path) + return str(project_dir), str(relative_path) + except ValueError: + # Si no se puede calcular la ruta relativa, usar ruta absoluta + self.logger.warning(f"No se puede calcular ruta relativa para {s7p_file_path}") + return str(project_dir), str(project_dir) + + except Exception as e: + self.logger.error(f"Error determinando directorio de proyecto para {s7p_file_path}: {e}") + return "", "" + + +# Funciones de utilidad para crear instancias +def create_everything_searcher(dll_path: str) -> Optional[EverythingSearcher]: + """Crear una instancia de EverythingSearcher con manejo de errores""" + try: + searcher = EverythingSearcher(dll_path) + if searcher.is_everything_available(): + return searcher + else: + return None + except Exception as e: + logging.getLogger(__name__).error(f"Error creando EverythingSearcher: {e}") + return None diff --git a/autobackups/src/utils/file_utils.py b/autobackups/src/utils/file_utils.py new file mode 100644 index 0000000..b6edf9c --- /dev/null +++ b/autobackups/src/utils/file_utils.py @@ -0,0 +1,247 @@ +""" +File Utilities +Utilidades para verificación de archivos y espacio en disco +""" + +import os +import shutil +import psutil +from pathlib import Path +from typing import Tuple, List, Optional, Dict +import logging + + +class FileChecker: + """Verificador de acceso a archivos y detección de archivos en uso""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def check_file_accessibility(self, file_path: str) -> Tuple[bool, str]: + """ + Verificar si un archivo es accesible para lectura + Retorna: (es_accesible, mensaje_error) + """ + try: + path = Path(file_path) + if not path.exists(): + return False, f"Archivo no existe: {file_path}" + + # Intentar abrir el archivo en modo lectura + with open(file_path, 'rb') as f: + # Leer un byte para verificar acceso real + f.read(1) + + return True, "" + + except PermissionError: + return False, f"Sin permisos para acceder: {file_path}" + except OSError as e: + if "being used by another process" in str(e).lower(): + return False, f"Archivo en uso: {file_path}" + return False, f"Error de sistema: {e}" + except Exception as e: + return False, f"Error inesperado: {e}" + + def check_directory_accessibility(self, directory_path: str, + check_all_files: bool = False) -> Tuple[bool, List[str]]: + """ + Verificar accesibilidad de un directorio + Si check_all_files es True, verifica todos los archivos + Retorna: (es_accesible, lista_errores) + """ + try: + path = Path(directory_path) + if not path.exists(): + return False, [f"Directorio no existe: {directory_path}"] + + if not path.is_dir(): + return False, [f"La ruta no es un directorio: {directory_path}"] + + errors = [] + + if check_all_files: + # Verificar todos los archivos en el directorio + for file_path in path.rglob("*"): + if file_path.is_file(): + accessible, error_msg = self.check_file_accessibility(str(file_path)) + if not accessible: + errors.append(error_msg) + else: + # Solo verificar acceso básico al directorio + try: + list(path.iterdir()) + except PermissionError: + errors.append(f"Sin permisos para acceder al directorio: {directory_path}") + except Exception as e: + errors.append(f"Error accediendo al directorio: {e}") + + return len(errors) == 0, errors + + except Exception as e: + return False, [f"Error verificando directorio {directory_path}: {e}"] + + def check_s7p_file_exclusively(self, s7p_file_path: str) -> Tuple[bool, str]: + """ + Verificar específicamente el archivo .s7p para exclusividad + Método rápido antes de procesar todo el proyecto + """ + try: + # Primero verificar acceso básico + accessible, error_msg = self.check_file_accessibility(s7p_file_path) + if not accessible: + return False, error_msg + + # Intentar crear un backup temporal para verificar exclusividad + import tempfile + temp_dir = tempfile.gettempdir() + temp_file = Path(temp_dir) / f"autobackup_test_{Path(s7p_file_path).name}" + + try: + shutil.copy2(s7p_file_path, temp_file) + # Si la copia fue exitosa, el archivo no está en uso exclusivo + temp_file.unlink() # Eliminar archivo temporal + return True, "" + except Exception as e: + if "being used by another process" in str(e).lower(): + return False, f"Archivo S7P en uso exclusivo: {s7p_file_path}" + return False, f"Error verificando exclusividad S7P: {e}" + + except Exception as e: + return False, f"Error verificando archivo S7P {s7p_file_path}: {e}" + + def is_file_locked(self, file_path: str) -> bool: + """ + Verificar si un archivo específico está bloqueado + """ + accessible, _ = self.check_file_accessibility(file_path) + return not accessible + + def get_locked_files_in_directory(self, directory_path: str) -> List[str]: + """ + Obtener lista de archivos bloqueados en un directorio + """ + locked_files = [] + try: + path = Path(directory_path) + for file_path in path.rglob("*"): + if file_path.is_file(): + if self.is_file_locked(str(file_path)): + locked_files.append(str(file_path)) + except Exception as e: + self.logger.error(f"Error buscando archivos bloqueados en {directory_path}: {e}") + + return locked_files + + +class DiskSpaceChecker: + """Verificador de espacio en disco""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def get_free_space_mb(self, path: str) -> float: + """ + Obtener espacio libre en MB para una ruta específica + """ + try: + if os.name == 'nt': # Windows + free_bytes = shutil.disk_usage(path).free + else: # Linux/Mac + statvfs = os.statvfs(path) + free_bytes = statvfs.f_frsize * statvfs.f_bavail + + return free_bytes / (1024 * 1024) # Convertir a MB + + except Exception as e: + self.logger.error(f"Error obteniendo espacio libre para {path}: {e}") + return 0.0 + + def get_disk_usage_info(self, path: str) -> Tuple[float, float, float]: + """ + Obtener información completa de uso de disco + Retorna: (total_mb, usado_mb, libre_mb) + """ + try: + if os.name == 'nt': # Windows + usage = shutil.disk_usage(path) + total_mb = usage.total / (1024 * 1024) + free_mb = usage.free / (1024 * 1024) + used_mb = total_mb - free_mb + else: # Linux/Mac + statvfs = os.statvfs(path) + total_mb = (statvfs.f_frsize * statvfs.f_blocks) / (1024 * 1024) + free_mb = (statvfs.f_frsize * statvfs.f_bavail) / (1024 * 1024) + used_mb = total_mb - free_mb + + return total_mb, used_mb, free_mb + + except Exception as e: + self.logger.error(f"Error obteniendo información de disco para {path}: {e}") + return 0.0, 0.0, 0.0 + + def has_sufficient_space(self, path: str, required_mb: float, + min_free_mb: float = 100.0) -> Tuple[bool, float]: + """ + Verificar si hay suficiente espacio para una operación + Retorna: (tiene_espacio_suficiente, espacio_libre_actual_mb) + """ + try: + free_mb = self.get_free_space_mb(path) + total_required = required_mb + min_free_mb + + return free_mb >= total_required, free_mb + + except Exception as e: + self.logger.error(f"Error verificando espacio suficiente para {path}: {e}") + return False, 0.0 + + def estimate_directory_size_mb(self, directory_path: str) -> float: + """ + Estimar el tamaño de un directorio en MB + """ + try: + total_size = 0 + path = Path(directory_path) + + for file_path in path.rglob("*"): + if file_path.is_file(): + try: + total_size += file_path.stat().st_size + except (OSError, PermissionError): + # Saltar archivos inaccesibles + continue + + return total_size / (1024 * 1024) # Convertir a MB + + except Exception as e: + self.logger.error(f"Error estimando tamaño de {directory_path}: {e}") + return 0.0 + + def get_system_disk_info(self) -> Dict[str, Tuple[float, float, float]]: + """ + Obtener información de todos los discos del sistema + Retorna: {drive: (total_mb, usado_mb, libre_mb)} + """ + disk_info = {} + try: + # Obtener todas las particiones + partitions = psutil.disk_partitions() + + for partition in partitions: + try: + usage = psutil.disk_usage(partition.mountpoint) + total_mb = usage.total / (1024 * 1024) + used_mb = usage.used / (1024 * 1024) + free_mb = usage.free / (1024 * 1024) + + disk_info[partition.device] = (total_mb, used_mb, free_mb) + + except PermissionError: + # Saltar unidades sin acceso + continue + + except Exception as e: + self.logger.error(f"Error obteniendo información del sistema: {e}") + + return disk_info diff --git a/autobackups/src/utils/hash_utils.py b/autobackups/src/utils/hash_utils.py new file mode 100644 index 0000000..2289e7c --- /dev/null +++ b/autobackups/src/utils/hash_utils.py @@ -0,0 +1,197 @@ +""" +Hash Utilities +Sistema de hash en dos etapas para optimizar detección de cambios +""" + +import hashlib +import os +from pathlib import Path +from typing import Dict, List, Tuple, Optional +import logging + + +class HashCalculator: + """Calculadora de hash optimizada para el sistema de dos etapas""" + + def __init__(self, algorithm: str = "md5"): + self.algorithm = algorithm.lower() + self.logger = logging.getLogger(__name__) + + def calculate_file_hash(self, file_path: str) -> str: + """ + Calcular hash de un archivo basado en timestamp + tamaño + No lee el contenido del archivo para mayor velocidad + """ + try: + path = Path(file_path) + if not path.exists(): + return "" + + stat = path.stat() + timestamp = str(stat.st_mtime) + size = str(stat.st_size) + + # Crear hash combinando timestamp y tamaño + hash_input = f"{timestamp}:{size}".encode('utf-8') + + if self.algorithm == "md5": + return hashlib.md5(hash_input).hexdigest() + elif self.algorithm == "sha256": + return hashlib.sha256(hash_input).hexdigest() + else: + raise ValueError(f"Algoritmo de hash no soportado: {self.algorithm}") + + except Exception as e: + self.logger.error(f"Error calculando hash para {file_path}: {e}") + return "" + + def calculate_s7p_hash(self, s7p_file_path: str) -> Tuple[str, str, int]: + """ + Calcular hash específico para archivo .s7p + Retorna: (hash, timestamp_iso, size_bytes) + """ + try: + path = Path(s7p_file_path) + if not path.exists(): + return "", "", 0 + + stat = path.stat() + timestamp = str(stat.st_mtime) + size = stat.st_size + + # Crear hash + hash_input = f"{timestamp}:{size}".encode('utf-8') + if self.algorithm == "md5": + file_hash = hashlib.md5(hash_input).hexdigest() + else: + file_hash = hashlib.sha256(hash_input).hexdigest() + + # Convertir timestamp a ISO format + from datetime import datetime + timestamp_iso = datetime.fromtimestamp(stat.st_mtime).isoformat() + + return file_hash, timestamp_iso, size + + except Exception as e: + self.logger.error(f"Error calculando hash S7P para {s7p_file_path}: {e}") + return "", "", 0 + + def calculate_directory_hash(self, directory_path: str, exclude_patterns: List[str] = None) -> Tuple[str, int, int]: + """ + Calcular hash de todos los archivos en un directorio + Retorna: (hash_completo, cantidad_archivos, tamaño_total) + """ + try: + path = Path(directory_path) + if not path.exists(): + return "", 0, 0 + + if exclude_patterns is None: + exclude_patterns = [] + + file_hashes = [] + file_count = 0 + total_size = 0 + + # Recorrer todos los archivos recursivamente + for file_path in path.rglob("*"): + if file_path.is_file(): + # Verificar si el archivo debe ser excluido + should_exclude = False + for pattern in exclude_patterns: + if pattern in str(file_path): + should_exclude = True + break + + if should_exclude: + continue + + try: + stat = file_path.stat() + file_hash = self.calculate_file_hash(str(file_path)) + if file_hash: + # Incluir la ruta relativa en el hash para detectar movimientos + relative_path = file_path.relative_to(path) + combined_hash_input = f"{relative_path}:{file_hash}".encode('utf-8') + + if self.algorithm == "md5": + combined_hash = hashlib.md5(combined_hash_input).hexdigest() + else: + combined_hash = hashlib.sha256(combined_hash_input).hexdigest() + + file_hashes.append(combined_hash) + file_count += 1 + total_size += stat.st_size + except Exception as e: + self.logger.warning(f"Error procesando archivo {file_path}: {e}") + continue + + # Crear hash final combinando todos los hashes de archivos + if file_hashes: + # Ordenar para asegurar consistencia + file_hashes.sort() + combined_input = ":".join(file_hashes).encode('utf-8') + + if self.algorithm == "md5": + final_hash = hashlib.md5(combined_input).hexdigest() + else: + final_hash = hashlib.sha256(combined_input).hexdigest() + + return final_hash, file_count, total_size + else: + return "", 0, 0 + + except Exception as e: + self.logger.error(f"Error calculando hash de directorio {directory_path}: {e}") + return "", 0, 0 + + def compare_hashes(self, hash1: str, hash2: str) -> bool: + """Comparar dos hashes de forma segura""" + if not hash1 or not hash2: + return False + return hash1 == hash2 + + def has_s7p_changed(self, s7p_file_path: str, last_known_hash: str, + last_known_timestamp: str, last_known_size: int) -> bool: + """ + Verificar si el archivo .s7p ha cambiado (Etapa 1 del sistema de hash) + """ + try: + current_hash, current_timestamp, current_size = self.calculate_s7p_hash(s7p_file_path) + + if not current_hash: + return True # Si no se puede calcular, asumir que cambió + + # Comparar hash actual con el último conocido + if not last_known_hash: + return True # Si no hay hash previo, definitivamente cambió + + return not self.compare_hashes(current_hash, last_known_hash) + + except Exception as e: + self.logger.error(f"Error verificando cambios en S7P {s7p_file_path}: {e}") + return True # En caso de error, asumir que cambió + + def has_directory_changed(self, directory_path: str, last_known_hash: str, + exclude_patterns: List[str] = None) -> Tuple[bool, str, int, int]: + """ + Verificar si el directorio ha cambiado (Etapa 2 del sistema de hash) + Retorna: (ha_cambiado, nuevo_hash, cantidad_archivos, tamaño_total) + """ + try: + current_hash, file_count, total_size = self.calculate_directory_hash( + directory_path, exclude_patterns + ) + + if not current_hash: + return True, "", 0, 0 + + if not last_known_hash: + return True, current_hash, file_count, total_size + + has_changed = not self.compare_hashes(current_hash, last_known_hash) + return has_changed, current_hash, file_count, total_size + + except Exception as e: + self.logger.error(f"Error verificando cambios en directorio {directory_path}: {e}") + return True, "", 0, 0