first commit
This commit is contained in:
commit
65f42d6bbb
24 changed files with 2540 additions and 0 deletions
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
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/
|
||||
|
||||
# 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
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.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
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.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/
|
||||
|
||||
# dfdr specific
|
||||
.dfdr/
|
||||
*.dfdr
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
185
DEVELOPMENT.md
Normal file
185
DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# dfdr Development Guide
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dfdr/
|
||||
├── .gitignore # Git ignore patterns
|
||||
├── Makefile # Development automation
|
||||
├── README.rst # Main documentation
|
||||
├── requirements.txt # Python dependencies
|
||||
├── setup.py # Package configuration
|
||||
├── DEVELOPMENT.md # This file
|
||||
├── dfdr/ # Main package
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ ├── cli.py # Command-line interface
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── checksum.py # MD5 checksum utilities
|
||||
│ ├── exceptions.py # Custom exceptions
|
||||
│ ├── fetcher.py # HTTP client with httpx
|
||||
│ └── storage.py # Storage and working copy management
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ └── test_basic.py # Basic functionality tests
|
||||
└── examples/ # Usage examples
|
||||
└── basic_usage.py # Programmatic usage example
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Create and activate a virtual environment:
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
```
|
||||
|
||||
2. Install in development mode:
|
||||
```bash
|
||||
make install-dev
|
||||
# or manually:
|
||||
pip install -e .
|
||||
pip install pytest pytest-cov black flake8 mypy
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
make test # Run basic tests
|
||||
make test-cov # Run tests with coverage report
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
make format # Format code with black
|
||||
make lint # Run linting with flake8 and mypy
|
||||
```
|
||||
|
||||
### Building and Distribution
|
||||
```bash
|
||||
make build # Build distribution packages
|
||||
make clean # Clean build artifacts
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Phase 1 Commands (Implemented)
|
||||
- `dfdr init` - Initialize a dfdr repository
|
||||
- `dfdr remote add <name> <url>` - Add remote data registry
|
||||
- `dfdr remote list` - List configured remotes
|
||||
- `dfdr remote remove <name>` - Remove a remote
|
||||
- `dfdr add <remote>:<file>` - Add file from remote to working copy
|
||||
- `dfdr fetch` - Fetch all files from remotes to storage
|
||||
- `dfdr pull [file]` - Update working copy from storage
|
||||
- `dfdr status` - Show file sync status
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Initialize repository
|
||||
dfdr init
|
||||
|
||||
# Add a remote data registry
|
||||
dfdr remote add mydata https://data.example.com/
|
||||
|
||||
# Add specific files
|
||||
dfdr add mydata:datasets/sales.csv
|
||||
dfdr add mydata:models/config.json
|
||||
|
||||
# Fetch all available files to local storage
|
||||
dfdr fetch
|
||||
|
||||
# Check status
|
||||
dfdr status
|
||||
|
||||
# Update working copy
|
||||
dfdr pull
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Config** (`config.py`): Manages remote registries and local configuration
|
||||
2. **Fetcher** (`fetcher.py`): HTTP client for downloading files and indices
|
||||
3. **Storage** (`storage.py`): Manages local storage and working copy operations
|
||||
4. **Checksum** (`checksum.py`): MD5 checksum calculation and verification
|
||||
5. **CLI** (`cli.py`): Command-line interface using Click and Rich
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Remote Registry**: HTTP server with `index.json` files listing available files
|
||||
2. **Local Storage**: `.dfdr/storage/` mirrors remote files
|
||||
3. **Working Copy**: Project files with corresponding `.dfdr` checksum files
|
||||
4. **Configuration**: `.dfdr/config.json` stores remote registry URLs
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── .dfdr/
|
||||
│ ├── config.json # Remote configurations
|
||||
│ └── storage/ # Local mirror of remote files
|
||||
│ └── remote_name/
|
||||
│ └── file.csv
|
||||
├── data_file.csv # Working copy file
|
||||
└── data_file.csv.dfdr # MD5 checksum
|
||||
```
|
||||
|
||||
## Protocol Specification
|
||||
|
||||
### Remote Data Registry
|
||||
|
||||
Remote registries must serve files over HTTP with the following structure:
|
||||
|
||||
1. **Index Files**: Each directory contains `index.json`:
|
||||
```json
|
||||
{
|
||||
"files": ["file1.csv", "file2.json", "subfolder/file3.yaml"]
|
||||
}
|
||||
```
|
||||
|
||||
2. **File Access**: Files are accessible via direct HTTP GET requests
|
||||
3. **Content Types**: Supports CSV, JSON, YAML, TXT files
|
||||
|
||||
### Local Storage
|
||||
|
||||
1. **Checksums**: Each data file has a corresponding `.dfdr` file containing MD5 hash
|
||||
2. **Storage Mirror**: `.dfdr/storage/` contains exact copies of remote files
|
||||
3. **Configuration**: JSON configuration file tracks remote registries
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Test coverage:
|
||||
```bash
|
||||
python -m pytest tests/ --cov=dfdr --cov-report=html
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make changes with tests
|
||||
4. Run `make format` and `make lint`
|
||||
5. Submit a pull request
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 Features
|
||||
- Directory synchronization
|
||||
- Nested folder support with recursive index.json
|
||||
- File metadata tracking (size, modification time)
|
||||
- Conflict resolution strategies
|
||||
- Incremental updates based on ETags/Last-Modified headers
|
||||
|
||||
### Advanced Features
|
||||
- Authentication support for private registries
|
||||
- Compression support
|
||||
- Parallel downloads
|
||||
- Registry mirroring
|
||||
- Plugin system for custom protocols
|
||||
235
README.md
Normal file
235
README.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# DefDer (Data Registry Definition) - dfdr
|
||||
|
||||
A Python command-line tool for managing local data registries, inspired by DVC and Git but focused specifically on data registry functionality.
|
||||
|
||||
## Overview
|
||||
|
||||
dfdr allows you to:
|
||||
|
||||
- Declare local data sources (folders containing CSV, JSON, YAML, TXT files)
|
||||
- Add specific files from data registries to your working copy
|
||||
- Mirror data locally for efficient access
|
||||
- Track file changes with checksums
|
||||
- Keep your working copy synchronized with local registries
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8 or higher
|
||||
- pip (Python package installer)
|
||||
|
||||
### Install from PyPI
|
||||
|
||||
```bash
|
||||
pip install dfdr
|
||||
```
|
||||
|
||||
### Install from source
|
||||
|
||||
```bash
|
||||
git clone git@defder.fr:dfdr.git
|
||||
cd dfdr
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Initialize a dfdr repository:
|
||||
|
||||
```bash
|
||||
dfdr init
|
||||
```
|
||||
|
||||
2. Add a local data registry:
|
||||
|
||||
```bash
|
||||
dfdr registry add myregistry /path/to/local/data/folder
|
||||
```
|
||||
|
||||
3. Add files from the registry to your working copy:
|
||||
|
||||
```bash
|
||||
dfdr add myregistry:datasets/sales.csv
|
||||
dfdr add myregistry:models/config.json
|
||||
```
|
||||
|
||||
4. Add a file to a specific subdirectory:
|
||||
|
||||
```bash
|
||||
dfdr add myregistry:datasets/customers.csv -d ./data/customers
|
||||
```
|
||||
|
||||
5. Update your local cache:
|
||||
|
||||
```bash
|
||||
dfdr fetch
|
||||
```
|
||||
|
||||
6. Check the status of your files:
|
||||
|
||||
```bash
|
||||
dfdr status
|
||||
```
|
||||
|
||||
7. Update your working copy:
|
||||
|
||||
```bash
|
||||
dfdr pull
|
||||
```
|
||||
|
||||
8. Push changes back to the registry:
|
||||
|
||||
```bash
|
||||
dfdr push datasets/sales.csv
|
||||
```
|
||||
|
||||
9. Move a file within the working copy:
|
||||
|
||||
```bash
|
||||
dfdr move models/config.json ./configs/model_config.json
|
||||
```
|
||||
|
||||
## Detailed Usage
|
||||
|
||||
### Managing Local Registries
|
||||
|
||||
```bash
|
||||
# Add a local registry
|
||||
dfdr registry add production /path/to/production/data
|
||||
|
||||
# List all registries
|
||||
dfdr registry list
|
||||
|
||||
# Remove a registry
|
||||
dfdr registry remove staging
|
||||
```
|
||||
|
||||
### Working with Files
|
||||
|
||||
```bash
|
||||
# Add a specific file
|
||||
dfdr add production:datasets/customer_data.csv
|
||||
|
||||
# Add a file to a specific subdirectory
|
||||
dfdr add production:datasets/sales.csv -d ./data/sales
|
||||
|
||||
# Add an entire directory
|
||||
dfdr add production:models
|
||||
|
||||
# Fetch all data to local cache
|
||||
dfdr fetch
|
||||
|
||||
# Update working copy (all files)
|
||||
dfdr pull
|
||||
|
||||
# Update a specific file
|
||||
dfdr pull datasets/customer_data.csv
|
||||
|
||||
# Check status of all tracked files
|
||||
dfdr status
|
||||
|
||||
# Push changes back to the registry
|
||||
dfdr push datasets/customer_data.csv
|
||||
|
||||
# Move a file within the working copy
|
||||
dfdr move models/config.json ./configs/model_config.json
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `dfdr init`
|
||||
|
||||
Initialize a new dfdr repository in the current directory.
|
||||
|
||||
### `dfdr registry`
|
||||
|
||||
Manage local data registries.
|
||||
|
||||
- `add <name> <path>`: Add a local data registry
|
||||
- `list`: List all configured registries
|
||||
- `remove <name>`: Remove a local data registry
|
||||
|
||||
### `dfdr add <remote_name>:<file_path> [-d <destination>]`
|
||||
|
||||
Add a specific file or directory from a registry to your working copy. Optionally specify a destination subdirectory.
|
||||
|
||||
### `dfdr fetch`
|
||||
|
||||
Mirror all data to local `.dfdr` storage from all remotes.
|
||||
|
||||
### `dfdr pull [file_path]`
|
||||
|
||||
Update working copy from cache (all files or specific file).
|
||||
|
||||
### `dfdr status`
|
||||
|
||||
Show sync status of files, including origin information.
|
||||
|
||||
### `dfdr push <file_path>`
|
||||
|
||||
Push changes in a file back to its origin data registry.
|
||||
|
||||
### `dfdr move <current_path> <new_path>`
|
||||
|
||||
Move a tracked file to a new destination within the working copy.
|
||||
|
||||
## Data registry structure
|
||||
|
||||
Local data registries should follow this structure:
|
||||
|
||||
- files are stored in local directories
|
||||
- the tool will automatically discover files in the directory
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
/path/to/registry/
|
||||
├── datasets/
|
||||
│ ├── sales.csv
|
||||
│ └── customers.json
|
||||
└── models/
|
||||
└── config.yaml
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
dfdr is designed with a modular architecture:
|
||||
|
||||
- `cli.py`: Command-line interface using Click and Rich for display
|
||||
- `config.py`: Configuration management (local registries, paths)
|
||||
- `storage.py`: Local storage management and synchronization
|
||||
- `fetcher.py`: Local file system operations
|
||||
- `checksum.py`: MD5 checksum calculation and verification
|
||||
- `exceptions.py`: Custom exception handling
|
||||
|
||||
## Local Storage
|
||||
|
||||
dfdr creates a `.dfdr` directory in your project containing:
|
||||
|
||||
- `config.json`: Local registry configuration
|
||||
- `storage/`: Local mirror of registry data
|
||||
- `*.dfdr` files: YAML files containing MD5 checksums and origin information for each data file (e.g., `sales.csv.dfdr`)
|
||||
|
||||
The `.dfdr` files now include additional information:
|
||||
- MD5 checksum of the file
|
||||
- Registry name (origin)
|
||||
- Original path in the registry
|
||||
|
||||
This enhanced structure allows for better tracking and management of files across different registries.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Create a virtual environment: `python -m venv venv`
|
||||
3. Activate the virtual environment:
|
||||
- On Windows: `venv\Scripts\activate`
|
||||
- On macOS and Linux: `source venv/bin/activate`
|
||||
4. Install development dependencies: `pip install -r requirements.txt`
|
||||
5. Install the package in editable mode: `pip install -e .`
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](licence.txt) file for details.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
110
TESTING.md
Normal file
110
TESTING.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Testing dfdr
|
||||
|
||||
This document outlines the test cases for verifying the functionality of the dfdr tool.
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1. Relative Paths for Registry List
|
||||
|
||||
- Add a registry using a relative path:
|
||||
```
|
||||
dfdr registry add test_registry ../test_data
|
||||
```
|
||||
- List registries and verify the path is displayed as relative:
|
||||
```
|
||||
dfdr registry list
|
||||
```
|
||||
- Use the registry to add a file:
|
||||
```
|
||||
dfdr add test_registry:sample.txt
|
||||
```
|
||||
|
||||
### 2. Specifying Destination Subdirectories
|
||||
|
||||
- Add a file to a specific subdirectory:
|
||||
```
|
||||
dfdr add test_registry:sample.txt -d ./subfolder
|
||||
```
|
||||
- Verify the file is placed in the correct location:
|
||||
```
|
||||
ls ./subfolder
|
||||
```
|
||||
- Check that the .dfdr file contains the correct origin information:
|
||||
```
|
||||
cat ./subfolder/sample.txt.dfdr
|
||||
```
|
||||
|
||||
### 3. Enhanced .dfdr Files with Origin Information
|
||||
|
||||
- Add a file from a registry:
|
||||
```
|
||||
dfdr add test_registry:config.json
|
||||
```
|
||||
- Examine the .dfdr file:
|
||||
```
|
||||
cat config.json.dfdr
|
||||
```
|
||||
- Use the `status` command to verify the origin information:
|
||||
```
|
||||
dfdr status
|
||||
```
|
||||
|
||||
### 4. Push Functionality
|
||||
|
||||
- Add a file from a registry:
|
||||
```
|
||||
dfdr add test_registry:data.csv
|
||||
```
|
||||
- Modify the file in the working copy:
|
||||
```
|
||||
echo "new data" >> data.csv
|
||||
```
|
||||
- Push changes back to the registry:
|
||||
```
|
||||
dfdr push data.csv
|
||||
```
|
||||
- Verify changes in the original registry:
|
||||
```
|
||||
cat ../test_data/data.csv
|
||||
```
|
||||
|
||||
### 5. Move Command
|
||||
|
||||
- Add a file from a registry:
|
||||
```
|
||||
dfdr add test_registry:script.py
|
||||
```
|
||||
- Move the file within the working copy:
|
||||
```
|
||||
dfdr move script.py ./src/script.py
|
||||
```
|
||||
- Verify the file is moved and .dfdr file is updated:
|
||||
```
|
||||
ls ./src
|
||||
cat ./src/script.py.dfdr
|
||||
```
|
||||
- Check status for correct location and origin information:
|
||||
```
|
||||
dfdr status
|
||||
```
|
||||
|
||||
### 6. Error Handling
|
||||
|
||||
- Attempt to push a non-existent file:
|
||||
```
|
||||
dfdr push non_existent_file.txt
|
||||
```
|
||||
- Try to move a file to an invalid destination:
|
||||
```
|
||||
dfdr move script.py /root/invalid_destination.py
|
||||
```
|
||||
- Add a file from a non-existent registry:
|
||||
```
|
||||
dfdr add fake_registry:file.txt
|
||||
```
|
||||
|
||||
## Running the Tests
|
||||
|
||||
To run these tests, set up a test environment with a sample data registry, then execute each command and verify the results match the expected behavior.
|
||||
|
||||
Remember to clean up the test environment after running the tests to ensure a clean state for future test runs.
|
||||
20
dfdr/__init__.py
Normal file
20
dfdr/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""
|
||||
DefDer (Data Registry Definition) - dfdr
|
||||
|
||||
A Python tool for managing remote data registries.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "DefDer Team"
|
||||
__email__ = "contact@defder.org"
|
||||
|
||||
from .exceptions import DfdrError, RemoteError, ChecksumError, ConfigError, FileSystemError, FileNotFoundError
|
||||
|
||||
__all__ = [
|
||||
"DfdrError",
|
||||
"RemoteError",
|
||||
"ChecksumError",
|
||||
"ConfigError",
|
||||
"FileSystemError",
|
||||
"FileNotFoundError",
|
||||
]
|
||||
125
dfdr/checksum.py
Normal file
125
dfdr/checksum.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""
|
||||
Checksum utilities for dfdr.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .exceptions import ChecksumError
|
||||
|
||||
|
||||
def calculate_md5(file_path: Path) -> str:
|
||||
"""
|
||||
Calculate MD5 checksum of a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
MD5 checksum as hexadecimal string
|
||||
|
||||
Raises:
|
||||
ChecksumError: If file cannot be read
|
||||
"""
|
||||
try:
|
||||
hash_md5 = hashlib.md5()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_md5.update(chunk)
|
||||
return hash_md5.hexdigest()
|
||||
except (OSError, IOError) as e:
|
||||
raise ChecksumError(f"Failed to calculate checksum for {file_path}: {e}")
|
||||
|
||||
|
||||
import yaml
|
||||
|
||||
def save_checksum(file_path: Path, checksum: str, registry_name: str, original_path: str) -> None:
|
||||
"""
|
||||
Save checksum and origin information to a .dfdr file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the data file
|
||||
checksum: MD5 checksum to save
|
||||
registry_name: Name of the registry the file came from
|
||||
original_path: Original path of the file in the registry
|
||||
|
||||
Raises:
|
||||
ChecksumError: If checksum file cannot be written
|
||||
"""
|
||||
checksum_path = Path(f"{file_path}.dfdr")
|
||||
data = {
|
||||
"checksum": checksum,
|
||||
"registry_name": registry_name,
|
||||
"original_path": original_path
|
||||
}
|
||||
try:
|
||||
with open(checksum_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f)
|
||||
except (OSError, IOError, yaml.YAMLError) as e:
|
||||
raise ChecksumError(f"Failed to save checksum and origin info to {checksum_path}: {e}")
|
||||
|
||||
def load_checksum_info(file_path: Path) -> Optional[dict]:
|
||||
"""
|
||||
Load checksum and origin information from a .dfdr file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the data file
|
||||
|
||||
Returns:
|
||||
Dictionary containing checksum, registry_name, and original_path if file exists, None otherwise
|
||||
|
||||
Raises:
|
||||
ChecksumError: If checksum file exists but cannot be read or parsed
|
||||
"""
|
||||
checksum_path = Path(f"{file_path}.dfdr")
|
||||
if not checksum_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(checksum_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data
|
||||
except (OSError, IOError, yaml.YAMLError) as e:
|
||||
raise ChecksumError(f"Failed to read checksum and origin info from {checksum_path}: {e}")
|
||||
|
||||
|
||||
def load_checksum(file_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Load checksum from a .dfdr file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the data file
|
||||
|
||||
Returns:
|
||||
MD5 checksum if file exists, None otherwise
|
||||
|
||||
Raises:
|
||||
ChecksumError: If checksum file exists but cannot be read
|
||||
"""
|
||||
info = load_checksum_info(file_path)
|
||||
return info["checksum"] if info else None
|
||||
|
||||
|
||||
def verify_checksum(file_path: Path) -> bool:
|
||||
"""
|
||||
Verify that a file's checksum matches its .dfdr file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the data file
|
||||
|
||||
Returns:
|
||||
True if checksums match, False if they don't or .dfdr file doesn't exist
|
||||
|
||||
Raises:
|
||||
ChecksumError: If files cannot be read
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return False
|
||||
|
||||
info = load_checksum_info(file_path)
|
||||
if info is None or "checksum" not in info:
|
||||
return False
|
||||
|
||||
current_checksum = calculate_md5(file_path)
|
||||
return info["checksum"] == current_checksum
|
||||
306
dfdr/cli.py
Normal file
306
dfdr/cli.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""
|
||||
Command-line interface for dfdr.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .config import Config
|
||||
from .storage import Storage
|
||||
from .exceptions import DfdrError
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def handle_error(func):
|
||||
"""Decorator to handle exceptions and display user-friendly error messages."""
|
||||
import functools
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except DfdrError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
return wrapper
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="0.1.0", prog_name="dfdr")
|
||||
def cli():
|
||||
"""
|
||||
DefDer (Data Registry Definition) - dfdr
|
||||
|
||||
A tool for managing local data registries.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@cli.group()
|
||||
def registry():
|
||||
"""Manage local data registries."""
|
||||
pass
|
||||
|
||||
|
||||
@registry.command("add")
|
||||
@click.argument("name")
|
||||
@click.argument("path")
|
||||
@handle_error
|
||||
def registry_add(name, path):
|
||||
"""Add a local data registry."""
|
||||
config = Config()
|
||||
config.add_remote(name, path)
|
||||
console.print(f"[green]✓[/green] Added local registry '{name}' -> {path}")
|
||||
|
||||
|
||||
@registry.command("list")
|
||||
@handle_error
|
||||
def registry_list():
|
||||
"""List all configured local registries."""
|
||||
config = Config()
|
||||
registries = config.list_remotes()
|
||||
|
||||
if not registries:
|
||||
console.print("No local registries configured.")
|
||||
return
|
||||
|
||||
table = Table(title="Configured Local Registries")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Path", style="blue")
|
||||
|
||||
for name, path in registries.items():
|
||||
table.add_row(name, str(path))
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@registry.command("remove")
|
||||
@click.argument("name")
|
||||
@handle_error
|
||||
def registry_remove(name):
|
||||
"""Remove a local data registry."""
|
||||
config = Config()
|
||||
config.remove_remote(name)
|
||||
console.print(f"[green]✓[/green] Removed local registry '{name}'")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file_spec")
|
||||
@click.option("--destination", "-d", help="Destination path in working copy")
|
||||
@handle_error
|
||||
def add(file_spec, destination):
|
||||
"""
|
||||
Add a file or directory from local registry to working copy.
|
||||
|
||||
FILE_SPEC should be in format: registry_name:file_path
|
||||
|
||||
Examples:
|
||||
dfdr add myregistry:sample.json # Add a single file
|
||||
dfdr add myregistry:analytics # Add entire directory
|
||||
dfdr add myregistry:sample.json -d ./subfolder # Add to specific destination
|
||||
"""
|
||||
if ":" not in file_spec:
|
||||
raise click.BadParameter("File spec must be in format 'registry_name:file_path'")
|
||||
|
||||
registry_name, file_path = file_spec.split(":", 1)
|
||||
|
||||
config = Config()
|
||||
storage = Storage(config)
|
||||
|
||||
with console.status(f"[bold green]Adding {file_path} from {registry_name}..."):
|
||||
storage.add_file(registry_name, file_path, destination)
|
||||
|
||||
console.print(f"[green]✓[/green] Added {file_path} from {registry_name}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def fetch():
|
||||
"""Fetch all files from local registries to local storage."""
|
||||
config = Config()
|
||||
storage = Storage(config)
|
||||
|
||||
with console.status("[bold green]Fetching files from local registries..."):
|
||||
fetched_files = storage.fetch_all()
|
||||
|
||||
total_files = sum(len(files) for files in fetched_files.values())
|
||||
|
||||
if total_files == 0:
|
||||
console.print("No files fetched.")
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Fetched {total_files} files")
|
||||
|
||||
for registry_name, files in fetched_files.items():
|
||||
if files:
|
||||
console.print(f" {registry_name}: {len(files)} files")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file_path", required=False)
|
||||
@handle_error
|
||||
def pull(file_path):
|
||||
"""
|
||||
Update working copy from storage.
|
||||
|
||||
If FILE_PATH is specified, only that file is updated.
|
||||
Otherwise, all files are updated.
|
||||
"""
|
||||
config = Config()
|
||||
storage = Storage(config)
|
||||
|
||||
if file_path:
|
||||
with console.status(f"[bold green]Pulling {file_path}..."):
|
||||
updated = storage.pull_file(file_path)
|
||||
|
||||
if updated:
|
||||
console.print(f"[green]✓[/green] Updated {file_path}")
|
||||
else:
|
||||
console.print(f"[yellow]•[/yellow] {file_path} is already up-to-date")
|
||||
else:
|
||||
with console.status("[bold green]Pulling all files..."):
|
||||
updated_files = storage.pull_all()
|
||||
|
||||
if updated_files:
|
||||
console.print(f"[green]✓[/green] Updated {len(updated_files)} files")
|
||||
for file in updated_files:
|
||||
console.print(f" {file}")
|
||||
else:
|
||||
console.print("[yellow]•[/yellow] All files are up-to-date")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def status():
|
||||
"""Show status of tracked files."""
|
||||
config = Config()
|
||||
storage = Storage(config)
|
||||
|
||||
file_status = storage.get_status()
|
||||
|
||||
if not file_status:
|
||||
console.print("No tracked files.")
|
||||
return
|
||||
|
||||
# Group files by status
|
||||
status_groups = {
|
||||
"up-to-date": [],
|
||||
"modified": [],
|
||||
"missing": [],
|
||||
"untracked": []
|
||||
}
|
||||
|
||||
for file_path, status in file_status.items():
|
||||
status_groups[status].append(file_path)
|
||||
|
||||
# Display results
|
||||
if status_groups["up-to-date"]:
|
||||
console.print(f"[green]Up-to-date files ({len(status_groups['up-to-date'])}):[/green]")
|
||||
for file in status_groups["up-to-date"]:
|
||||
info = storage.get_file_info(file)
|
||||
console.print(f" [green]✓[/green] {file} (from {info['registry_name']}:{info['original_path']})")
|
||||
console.print()
|
||||
|
||||
if status_groups["modified"]:
|
||||
console.print(f"[yellow]Modified files ({len(status_groups['modified'])}):[/yellow]")
|
||||
for file in status_groups["modified"]:
|
||||
info = storage.get_file_info(file)
|
||||
console.print(f" [yellow]M[/yellow] {file} (from {info['registry_name']}:{info['original_path']})")
|
||||
console.print()
|
||||
|
||||
if status_groups["missing"]:
|
||||
console.print(f"[red]Missing files ({len(status_groups['missing'])}):[/red]")
|
||||
for file in status_groups["missing"]:
|
||||
info = storage.get_file_info(file)
|
||||
console.print(f" [red]![/red] {file} (from {info['registry_name']}:{info['original_path']})")
|
||||
console.print()
|
||||
|
||||
if status_groups["untracked"]:
|
||||
console.print(f"[blue]Untracked files ({len(status_groups['untracked'])}):[/blue]")
|
||||
for file in status_groups["untracked"]:
|
||||
console.print(f" [blue]?[/blue] {file}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def init():
|
||||
"""Initialize a new dfdr repository in the current directory."""
|
||||
config = Config()
|
||||
|
||||
if config.is_initialized():
|
||||
console.print("[yellow]Repository already initialized.[/yellow]")
|
||||
return
|
||||
|
||||
# Config initialization happens automatically in Config.__init__()
|
||||
console.print(f"[green]✓[/green] Initialized dfdr repository in {config.project_root}")
|
||||
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def validate():
|
||||
"""Validate all configured local registries."""
|
||||
config = Config()
|
||||
|
||||
with console.status("[bold green]Validating local registries..."):
|
||||
config.validate_local_registries()
|
||||
|
||||
console.print("[green]✓[/green] All local registries are valid.")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file_path")
|
||||
@handle_error
|
||||
def push(file_path):
|
||||
"""
|
||||
Push changes in a file back to its origin data registry.
|
||||
|
||||
FILE_PATH: Path of the file to push (relative to project root)
|
||||
"""
|
||||
config = Config()
|
||||
storage = Storage(config)
|
||||
|
||||
with console.status(f"[bold green]Pushing {file_path}..."):
|
||||
pushed = storage.push_file(file_path)
|
||||
|
||||
if pushed:
|
||||
console.print(f"[green]✓[/green] Pushed changes in {file_path} to origin")
|
||||
else:
|
||||
console.print(f"[yellow]•[/yellow] No changes to push for {file_path}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("file_path")
|
||||
@click.argument("new_destination")
|
||||
@handle_error
|
||||
def move(file_path, new_destination):
|
||||
"""
|
||||
Move a tracked file to a new destination within the working copy.
|
||||
|
||||
FILE_PATH: Current path of the file (relative to project root)
|
||||
NEW_DESTINATION: New destination path for the file (relative to project root)
|
||||
"""
|
||||
config = Config()
|
||||
storage = Storage(config)
|
||||
|
||||
with console.status(f"[bold green]Moving {file_path} to {new_destination}..."):
|
||||
storage.update_file_destination(file_path, new_destination)
|
||||
|
||||
console.print(f"[green]✓[/green] Moved {file_path} to {new_destination}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
144
dfdr/config.py
Normal file
144
dfdr/config.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""
|
||||
Configuration management for dfdr.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .exceptions import ConfigError
|
||||
|
||||
|
||||
class Config:
|
||||
"""Manages dfdr configuration."""
|
||||
|
||||
def __init__(self, project_root: Optional[Path] = None):
|
||||
"""
|
||||
Initialize configuration.
|
||||
|
||||
Args:
|
||||
project_root: Root directory of the project. If None, uses current directory.
|
||||
"""
|
||||
self.project_root = project_root or Path.cwd()
|
||||
self.dfdr_dir = self.project_root / ".dfdr"
|
||||
self.config_file = self.dfdr_dir / "config.json"
|
||||
self.storage_dir = self.dfdr_dir / "storage"
|
||||
|
||||
# Ensure directories exist
|
||||
self.dfdr_dir.mkdir(exist_ok=True)
|
||||
self.storage_dir.mkdir(exist_ok=True)
|
||||
|
||||
self._config = self._load_config()
|
||||
|
||||
def _load_config(self) -> Dict:
|
||||
"""Load configuration from file."""
|
||||
if not self.config_file.exists():
|
||||
return {"remotes": {}}
|
||||
|
||||
try:
|
||||
with open(self.config_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
raise ConfigError(f"Failed to load config from {self.config_file}: {e}")
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
with open(self.config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._config, f, indent=2)
|
||||
except (OSError, IOError) as e:
|
||||
raise ConfigError(f"Failed to save config to {self.config_file}: {e}")
|
||||
|
||||
def add_remote(self, name: str, path: str) -> None:
|
||||
"""
|
||||
Add a local registry.
|
||||
|
||||
Args:
|
||||
name: Name of the local registry
|
||||
path: Path to the local registry
|
||||
|
||||
Raises:
|
||||
ConfigError: If local registry already exists or path is invalid
|
||||
"""
|
||||
if name in self._config["remotes"]:
|
||||
raise ConfigError(f"Local registry '{name}' already exists")
|
||||
|
||||
local_path = Path(path).resolve()
|
||||
if not local_path.is_dir():
|
||||
raise ConfigError(f"Invalid path for local registry: {path}")
|
||||
|
||||
self._config["remotes"][name] = {"path": str(local_path)}
|
||||
self._save_config()
|
||||
|
||||
def remove_remote(self, name: str) -> None:
|
||||
"""
|
||||
Remove a local registry.
|
||||
|
||||
Args:
|
||||
name: Name of the local registry to remove
|
||||
|
||||
Raises:
|
||||
ConfigError: If local registry doesn't exist
|
||||
"""
|
||||
if name not in self._config["remotes"]:
|
||||
raise ConfigError(f"Local registry '{name}' does not exist")
|
||||
|
||||
del self._config["remotes"][name]
|
||||
self._save_config()
|
||||
|
||||
def get_remote_path(self, name: str) -> Path:
|
||||
"""
|
||||
Get absolute path for a local registry.
|
||||
|
||||
Args:
|
||||
name: Name of the local registry
|
||||
|
||||
Returns:
|
||||
Absolute Path of the local registry
|
||||
|
||||
Raises:
|
||||
ConfigError: If local registry doesn't exist
|
||||
"""
|
||||
if name not in self._config["remotes"]:
|
||||
raise ConfigError(f"Local registry '{name}' does not exist")
|
||||
|
||||
return Path(self._config["remotes"][name]["path"])
|
||||
|
||||
def list_remotes(self) -> Dict[str, Path]:
|
||||
"""
|
||||
List all configured local registries.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping local registry names to absolute paths
|
||||
"""
|
||||
return {name: Path(remote["path"]) for name, remote in self._config["remotes"].items()}
|
||||
|
||||
def get_storage_path(self, remote_name: str, file_path: str) -> Path:
|
||||
"""
|
||||
Get local storage path for a file from a local registry.
|
||||
|
||||
Args:
|
||||
remote_name: Name of the local registry
|
||||
file_path: Path of the file in the local registry (already relative to remote)
|
||||
|
||||
Returns:
|
||||
Local path where the file should be stored
|
||||
"""
|
||||
# file_path is already relative to the remote, so just use it directly
|
||||
return self.storage_dir / remote_name / file_path
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
"""Check if the current directory is a dfdr repository."""
|
||||
return self.dfdr_dir.exists() and self.config_file.exists()
|
||||
|
||||
def validate_local_registries(self) -> None:
|
||||
"""
|
||||
Validate all configured local registries.
|
||||
|
||||
Raises:
|
||||
ConfigError: If any local registry path is invalid
|
||||
"""
|
||||
for name, remote in self._config["remotes"].items():
|
||||
path = Path(remote["path"])
|
||||
if not path.is_dir():
|
||||
raise ConfigError(f"Invalid path for local registry '{name}': {path}")
|
||||
37
dfdr/exceptions.py
Normal file
37
dfdr/exceptions.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""
|
||||
Custom exceptions for dfdr.
|
||||
"""
|
||||
|
||||
|
||||
class DfdrError(Exception):
|
||||
"""Base exception for all dfdr errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RemoteError(DfdrError):
|
||||
"""Exception raised for remote registry errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ChecksumError(DfdrError):
|
||||
"""Exception raised for checksum validation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigError(DfdrError):
|
||||
"""Exception raised for configuration errors."""
|
||||
pass
|
||||
|
||||
|
||||
class FileNotFoundError(DfdrError):
|
||||
"""Exception raised when a file is not found in the registry."""
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(DfdrError):
|
||||
"""Exception raised for network-related errors."""
|
||||
pass
|
||||
|
||||
class FileSystemError(DfdrError):
|
||||
"""Exception raised for file system related errors."""
|
||||
pass
|
||||
115
dfdr/fetcher.py
Normal file
115
dfdr/fetcher.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""
|
||||
Local file system utilities for dfdr.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import shutil
|
||||
|
||||
from .exceptions import FileSystemError, FileNotFoundError
|
||||
|
||||
|
||||
class Fetcher:
|
||||
"""Handles local file system operations for data registries."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the fetcher."""
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
pass
|
||||
|
||||
def fetch_index(self, base_path: Path, directory: str = "") -> List[str]:
|
||||
"""
|
||||
List files in a local directory.
|
||||
|
||||
Args:
|
||||
base_path: Base path of the local registry
|
||||
directory: Directory path (empty for root)
|
||||
|
||||
Returns:
|
||||
List of files in the directory
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If directory doesn't exist
|
||||
FileSystemError: If there's an error reading the directory
|
||||
"""
|
||||
dir_path = base_path / directory
|
||||
|
||||
if not dir_path.exists():
|
||||
raise FileNotFoundError(f"Directory not found: {dir_path}")
|
||||
|
||||
if not dir_path.is_dir():
|
||||
raise FileSystemError(f"Path is not a directory: {dir_path}")
|
||||
|
||||
try:
|
||||
return [f.name for f in dir_path.iterdir() if f.is_file()]
|
||||
except Exception as e:
|
||||
raise FileSystemError(f"Error reading directory {dir_path}: {e}")
|
||||
|
||||
def fetch_file(self, base_path: Path, file_path: str, output_path: Path) -> None:
|
||||
"""
|
||||
Copy a file from the local registry to the output path.
|
||||
|
||||
Args:
|
||||
base_path: Base path of the local registry
|
||||
file_path: Path of the file to fetch
|
||||
output_path: Local path to save the file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file doesn't exist
|
||||
FileSystemError: If there's an error copying the file
|
||||
"""
|
||||
source_path = base_path / file_path
|
||||
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {source_path}")
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
shutil.copy2(source_path, output_path)
|
||||
except Exception as e:
|
||||
raise FileSystemError(f"Failed to copy file from {source_path} to {output_path}: {e}")
|
||||
|
||||
def check_file_exists(self, base_path: Path, file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists in the local registry.
|
||||
|
||||
Args:
|
||||
base_path: Base path of the local registry
|
||||
file_path: Path of the file to check
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
return (base_path / file_path).is_file()
|
||||
|
||||
def get_file_info(self, base_path: Path, file_path: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get file information from local registry.
|
||||
|
||||
Args:
|
||||
base_path: Base path of the local registry
|
||||
file_path: Path of the file
|
||||
|
||||
Returns:
|
||||
Dictionary with file info or None if file doesn't exist
|
||||
"""
|
||||
file_path = base_path / file_path
|
||||
if not file_path.is_file():
|
||||
return None
|
||||
|
||||
stat = file_path.stat()
|
||||
return {
|
||||
"size": stat.st_size,
|
||||
"last_modified": stat.st_mtime,
|
||||
"created": stat.st_ctime,
|
||||
}
|
||||
474
dfdr/storage.py
Normal file
474
dfdr/storage.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
"""
|
||||
Storage management for dfdr.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .checksum import calculate_md5, save_checksum, load_checksum, verify_checksum, load_checksum_info
|
||||
from .config import Config
|
||||
from .fetcher import Fetcher
|
||||
from .exceptions import DfdrError, FileNotFoundError, FileSystemError
|
||||
|
||||
|
||||
class Storage:
|
||||
"""Manages local storage and working copy operations."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
"""
|
||||
Initialize storage manager.
|
||||
|
||||
Args:
|
||||
config: Configuration instance
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def add_file(self, remote_name: str, file_path: str, destination: Optional[str] = None) -> None:
|
||||
"""
|
||||
Add a file or directory from local registry to working copy.
|
||||
|
||||
Args:
|
||||
remote_name: Name of the local registry
|
||||
file_path: Path of the file or directory in the local registry
|
||||
destination: Optional destination path in the working copy
|
||||
|
||||
Raises:
|
||||
DfdrError: If operation fails
|
||||
"""
|
||||
# Get remote path
|
||||
remote_path = Path(self.config.get_remote_path(remote_name))
|
||||
full_remote_path = (remote_path / file_path).resolve()
|
||||
|
||||
if not full_remote_path.exists():
|
||||
raise FileNotFoundError(f"File or directory '{file_path}' does not exist in the remote path '{remote_path}'")
|
||||
|
||||
if full_remote_path.is_file():
|
||||
self._add_single_file(remote_name, remote_path, file_path, None, destination)
|
||||
elif full_remote_path.is_dir():
|
||||
directory_files = [f.name for f in full_remote_path.iterdir() if f.is_file()]
|
||||
self._add_directory(remote_name, remote_path, file_path, directory_files, None, destination)
|
||||
else:
|
||||
raise ValueError(f"'{file_path}' is neither a file nor a directory")
|
||||
|
||||
def _add_single_file(self, remote_name: str, remote_path: Path, file_path: str, fetcher: Optional[Fetcher], destination: Optional[str] = None) -> None:
|
||||
"""Add a single file from local registry to working copy."""
|
||||
# Resolve both paths to ensure consistent comparison
|
||||
resolved_remote_path = remote_path.resolve()
|
||||
full_remote_path = (remote_path / file_path).resolve()
|
||||
|
||||
if not full_remote_path.exists():
|
||||
raise FileNotFoundError(f"File '{file_path}' does not exist in the remote path '{remote_path}'")
|
||||
|
||||
if not full_remote_path.is_file():
|
||||
raise ValueError(f"'{file_path}' is not a file")
|
||||
|
||||
try:
|
||||
relative_path = full_remote_path.relative_to(resolved_remote_path)
|
||||
except ValueError:
|
||||
raise ValueError(f"'{full_remote_path}' is not in the subpath of '{resolved_remote_path}'")
|
||||
|
||||
filename = relative_path.name
|
||||
|
||||
if destination:
|
||||
working_copy_path = self.config.project_root / destination / filename
|
||||
else:
|
||||
working_copy_path = self.config.project_root / filename
|
||||
|
||||
storage_path = self.config.get_storage_path(remote_name, str(relative_path))
|
||||
|
||||
# Copy file to storage
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(full_remote_path, "rb") as src, open(storage_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Copy to working copy
|
||||
working_copy_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(storage_path, "rb") as src, open(working_copy_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Calculate and save checksum with origin information
|
||||
checksum = calculate_md5(working_copy_path)
|
||||
save_checksum(working_copy_path, checksum, remote_name, str(relative_path))
|
||||
|
||||
|
||||
def _add_directory(self, remote_name: str, remote_path: Path, dir_path: str, files: List[str], fetcher: Optional[Fetcher], destination: Optional[str] = None) -> None:
|
||||
"""Add all files from a directory to working copy."""
|
||||
# Create the directory in working copy
|
||||
if destination:
|
||||
working_copy_dir = self.config.project_root / destination / Path(dir_path).name
|
||||
else:
|
||||
working_copy_dir = self.config.project_root / Path(dir_path).name
|
||||
working_copy_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_name in files:
|
||||
# Full path in local registry
|
||||
full_file_path = f"{dir_path}/{file_name}"
|
||||
full_remote_path = remote_path / full_file_path
|
||||
# Storage path (preserves full structure)
|
||||
storage_path = self.config.get_storage_path(remote_name, full_file_path)
|
||||
|
||||
# Working copy path (preserves directory structure)
|
||||
if destination:
|
||||
working_copy_path = working_copy_dir / file_name
|
||||
else:
|
||||
working_copy_path = working_copy_dir / file_name
|
||||
|
||||
try:
|
||||
# Copy file to storage
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(full_remote_path, "rb") as src, open(storage_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Copy to working copy
|
||||
working_copy_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(storage_path, "rb") as src, open(working_copy_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Calculate and save checksum with origin information
|
||||
checksum = calculate_md5(working_copy_path)
|
||||
save_checksum(working_copy_path, checksum, remote_name, full_file_path)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
pass # Silently skip failed files
|
||||
|
||||
def fetch_all(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Fetch all files from all local registries to storage.
|
||||
Also fetches files from subdirectories that have been previously added.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping local registry names to lists of fetched files
|
||||
|
||||
Raises:
|
||||
DfdrError: If operation fails
|
||||
"""
|
||||
fetched_files = {}
|
||||
|
||||
with Fetcher() as fetcher:
|
||||
for remote_name, remote_path_str in self.config.list_remotes().items():
|
||||
remote_path = Path(remote_path_str)
|
||||
fetched_files[remote_name] = []
|
||||
|
||||
try:
|
||||
# Get root index
|
||||
files = fetcher.fetch_index(remote_path)
|
||||
|
||||
for file_path in files:
|
||||
storage_path = self.config.get_storage_path(remote_name, file_path)
|
||||
|
||||
try:
|
||||
fetcher.fetch_file(remote_path, file_path, storage_path)
|
||||
fetched_files[remote_name].append(file_path)
|
||||
except Exception as e:
|
||||
# Continue with other files if one fails
|
||||
pass # Silently skip failed files
|
||||
|
||||
# Also fetch from subdirectories that have been previously added
|
||||
# Look for mapping files to find directories we've added
|
||||
remote_storage = self.config.storage_dir / remote_name
|
||||
if remote_storage.exists():
|
||||
for mapping_file in remote_storage.rglob("*.mapping"):
|
||||
try:
|
||||
with open(mapping_file, "r") as f:
|
||||
original_path = f.read().strip()
|
||||
|
||||
# If this is a file in a subdirectory, fetch the whole directory
|
||||
if "/" in original_path:
|
||||
dir_path = original_path.split("/")[0]
|
||||
|
||||
try:
|
||||
# Fetch directory index
|
||||
dir_files = fetcher.fetch_index(remote_path, dir_path)
|
||||
|
||||
for file_name in dir_files:
|
||||
full_file_path = f"{dir_path}/{file_name}"
|
||||
storage_path = self.config.get_storage_path(remote_name, full_file_path)
|
||||
|
||||
try:
|
||||
fetcher.fetch_file(remote_path, full_file_path, storage_path)
|
||||
if full_file_path not in fetched_files[remote_name]:
|
||||
fetched_files[remote_name].append(full_file_path)
|
||||
except Exception as e:
|
||||
pass # Silently skip failed files
|
||||
|
||||
except Exception as e:
|
||||
# Directory might not have an index, skip silently
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# Skip invalid mapping files
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
pass # Silently skip failed registries
|
||||
|
||||
return fetched_files
|
||||
|
||||
def pull_file(self, file_path: str) -> bool:
|
||||
"""
|
||||
Pull a specific file from storage to working copy.
|
||||
|
||||
Args:
|
||||
file_path: Path of the file to pull (can be full path or just filename)
|
||||
|
||||
Returns:
|
||||
True if file was updated, False if already up-to-date
|
||||
|
||||
Raises:
|
||||
DfdrError: If file not found in any remote storage
|
||||
"""
|
||||
# Determine working copy path - preserve directory structure if it's a full path
|
||||
if "/" in file_path:
|
||||
working_copy_path = self.config.project_root / file_path
|
||||
else:
|
||||
# For single files, use just the filename
|
||||
working_copy_path = self.config.project_root / file_path
|
||||
|
||||
# Find the file in storage (could be stored with full path)
|
||||
storage_path = None
|
||||
for remote_name in self.config.list_remotes():
|
||||
# Try the exact path first
|
||||
candidate_path = self.config.get_storage_path(remote_name, file_path)
|
||||
if candidate_path.exists():
|
||||
storage_path = candidate_path
|
||||
break
|
||||
# Also try looking for files that match the filename
|
||||
remote_storage = self.config.storage_dir / remote_name
|
||||
if remote_storage.exists():
|
||||
for storage_file in remote_storage.rglob("*"):
|
||||
if storage_file.is_file() and not storage_file.name.endswith('.mapping'):
|
||||
# Check if this matches our target file
|
||||
rel_path = str(storage_file.relative_to(remote_storage))
|
||||
if rel_path == file_path or storage_file.name == Path(file_path).name:
|
||||
storage_path = storage_file
|
||||
break
|
||||
if storage_path:
|
||||
break
|
||||
|
||||
if storage_path is None:
|
||||
raise FileNotFoundError(f"File '{file_path}' not found in any remote storage")
|
||||
|
||||
# Check if update is needed
|
||||
if working_copy_path.exists():
|
||||
storage_checksum = calculate_md5(storage_path)
|
||||
working_checksum = calculate_md5(working_copy_path)
|
||||
|
||||
if working_checksum == storage_checksum:
|
||||
return False # Already up-to-date
|
||||
|
||||
# Copy from storage to working copy
|
||||
working_copy_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(storage_path, "rb") as src, open(working_copy_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Update checksum, preserving registry and path info
|
||||
checksum = calculate_md5(working_copy_path)
|
||||
# Get original info from storage path
|
||||
storage_rel_path = storage_path.relative_to(self.config.storage_dir)
|
||||
registry_name = storage_rel_path.parts[0] # First part is registry name
|
||||
original_path = str(Path(*storage_rel_path.parts[1:])) # Rest is the original path
|
||||
save_checksum(working_copy_path, checksum, registry_name, original_path)
|
||||
|
||||
return True
|
||||
|
||||
def pull_all(self) -> List[str]:
|
||||
"""
|
||||
Pull all tracked files from storage to working copy.
|
||||
Only pulls files that have corresponding .dfdr checksum files (i.e., were explicitly added).
|
||||
|
||||
Returns:
|
||||
List of files that were updated
|
||||
"""
|
||||
updated_files = []
|
||||
|
||||
# Only pull files that have .dfdr checksum files (explicitly tracked)
|
||||
for dfdr_file in self.config.project_root.rglob("*.dfdr"):
|
||||
data_file = Path(str(dfdr_file)[:-5]) # Remove .dfdr extension
|
||||
|
||||
# Only process if the corresponding data file is actually a file, not a directory
|
||||
if not data_file.is_file():
|
||||
continue
|
||||
|
||||
# Get the relative path from project root for proper tracking
|
||||
rel_path = str(data_file.relative_to(self.config.project_root))
|
||||
|
||||
try:
|
||||
if self.pull_file(rel_path):
|
||||
updated_files.append(rel_path)
|
||||
except Exception as e:
|
||||
pass # Silently skip failed files
|
||||
|
||||
return updated_files
|
||||
|
||||
def get_status(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get status of all tracked files.
|
||||
Only shows files that were explicitly added (have .dfdr checksum files).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to status:
|
||||
- "up-to-date": File matches storage
|
||||
- "modified": File differs from storage
|
||||
- "missing": File was tracked but is missing from working copy
|
||||
- "untracked": File exists in working copy but not properly tracked
|
||||
"""
|
||||
status = {}
|
||||
|
||||
# Only check files that have .dfdr checksum files (explicitly tracked)
|
||||
for dfdr_file in self.config.project_root.rglob("*.dfdr"):
|
||||
data_file = Path(str(dfdr_file)[:-5]) # Remove .dfdr extension
|
||||
|
||||
# Only include if it's actually a file, not a directory
|
||||
if not data_file.is_file():
|
||||
continue
|
||||
|
||||
# Get the relative path from project root for proper tracking
|
||||
rel_path = str(data_file.relative_to(self.config.project_root))
|
||||
|
||||
# Find corresponding file in storage
|
||||
storage_path = None
|
||||
for remote_name in self.config.list_remotes():
|
||||
remote_storage = self.config.storage_dir / remote_name
|
||||
if remote_storage.exists():
|
||||
for storage_file in remote_storage.rglob("*"):
|
||||
if storage_file.is_file() and not storage_file.name.endswith('.mapping'):
|
||||
# Check if this matches our target file (by relative path or filename)
|
||||
storage_rel_path = str(storage_file.relative_to(remote_storage))
|
||||
if storage_rel_path == rel_path or storage_file.name == data_file.name:
|
||||
storage_path = storage_file
|
||||
break
|
||||
if storage_path:
|
||||
break
|
||||
|
||||
if storage_path is None:
|
||||
status[rel_path] = "untracked"
|
||||
elif not data_file.exists():
|
||||
status[rel_path] = "missing"
|
||||
else:
|
||||
# Check if checksum matches storage
|
||||
storage_checksum = calculate_md5(storage_path)
|
||||
working_checksum = calculate_md5(data_file)
|
||||
|
||||
if working_checksum == storage_checksum:
|
||||
status[rel_path] = "up-to-date"
|
||||
else:
|
||||
status[rel_path] = "modified"
|
||||
|
||||
return status
|
||||
|
||||
def push_file(self, file_path: str) -> bool:
|
||||
"""
|
||||
Push changes in a file back to its origin data registry.
|
||||
|
||||
Args:
|
||||
file_path: Path of the file to push (relative to project root)
|
||||
|
||||
Returns:
|
||||
True if file was pushed, False if no changes were needed
|
||||
|
||||
Raises:
|
||||
DfdrError: If file not found or not tracked
|
||||
"""
|
||||
working_copy_path = self.config.project_root / file_path
|
||||
|
||||
if not working_copy_path.exists():
|
||||
raise FileNotFoundError(f"File '{file_path}' not found in working copy")
|
||||
|
||||
# Load checksum info
|
||||
checksum_info = load_checksum_info(working_copy_path)
|
||||
if checksum_info is None:
|
||||
raise DfdrError(f"File '{file_path}' is not tracked")
|
||||
|
||||
remote_name = checksum_info["registry_name"]
|
||||
original_path = checksum_info["original_path"]
|
||||
|
||||
# Get remote path and storage path
|
||||
remote_path = self.config.get_remote_path(remote_name)
|
||||
storage_path = self.config.get_storage_path(remote_name, original_path)
|
||||
|
||||
# Calculate current checksum
|
||||
current_checksum = calculate_md5(working_copy_path)
|
||||
|
||||
if current_checksum == checksum_info["checksum"]:
|
||||
# Even if no changes, ensure storage is in sync
|
||||
if storage_path.exists():
|
||||
storage_checksum = calculate_md5(storage_path)
|
||||
if storage_checksum != current_checksum:
|
||||
# Update storage to match working copy
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(working_copy_path, "rb") as src, open(storage_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
return False # No changes to push to remote
|
||||
|
||||
# Copy file to remote
|
||||
remote_file_path = remote_path / original_path
|
||||
remote_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(working_copy_path, "rb") as src, open(remote_file_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Update storage copy
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(working_copy_path, "rb") as src, open(storage_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
# Update checksum info
|
||||
save_checksum(working_copy_path, current_checksum, remote_name, original_path)
|
||||
|
||||
return True
|
||||
|
||||
def get_file_info(self, file_path: str) -> Dict[str, str]:
|
||||
"""
|
||||
Get origin information for a tracked file.
|
||||
|
||||
Args:
|
||||
file_path: Path of the file (relative to project root)
|
||||
|
||||
Returns:
|
||||
Dictionary containing registry_name and original_path
|
||||
|
||||
Raises:
|
||||
DfdrError: If file is not tracked
|
||||
"""
|
||||
working_copy_path = self.config.project_root / file_path
|
||||
|
||||
checksum_info = load_checksum_info(working_copy_path)
|
||||
if checksum_info is None:
|
||||
raise DfdrError(f"File '{file_path}' is not tracked")
|
||||
|
||||
return {
|
||||
"registry_name": checksum_info["registry_name"],
|
||||
"original_path": checksum_info["original_path"]
|
||||
}
|
||||
|
||||
def update_file_destination(self, file_path: str, new_destination: str) -> None:
|
||||
"""
|
||||
Update the destination of a tracked file.
|
||||
|
||||
Args:
|
||||
file_path: Current path of the file (relative to project root)
|
||||
new_destination: New destination path for the file (relative to project root)
|
||||
|
||||
Raises:
|
||||
DfdrError: If file is not tracked or new destination is invalid
|
||||
"""
|
||||
working_copy_path = self.config.project_root / file_path
|
||||
new_path = self.config.project_root / new_destination
|
||||
|
||||
if not working_copy_path.exists():
|
||||
raise FileNotFoundError(f"File '{file_path}' not found in working copy")
|
||||
|
||||
checksum_info = load_checksum_info(working_copy_path)
|
||||
if checksum_info is None:
|
||||
raise DfdrError(f"File '{file_path}' is not tracked")
|
||||
|
||||
# Move the file to the new destination
|
||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
working_copy_path.rename(new_path)
|
||||
|
||||
# Update the checksum file
|
||||
checksum = calculate_md5(new_path)
|
||||
save_checksum(new_path, checksum, checksum_info["registry_name"], checksum_info["original_path"])
|
||||
|
||||
# Remove the old checksum file
|
||||
(working_copy_path.parent / f"{working_copy_path.name}.dfdr").unlink()
|
||||
3
essai/analytics/metrics.csv
Normal file
3
essai/analytics/metrics.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
date,page_views,unique_visitors,bounce_rate,avg_session_duration,conversion_rate
|
||||
2025-05-01,1250,890,0.42,185.5,0.034
|
||||
2025-05-04,980,720,0.45,165.3,0.029
|
||||
|
53
essai/analytics/settings.yaml
Normal file
53
essai/analytics/settings.yaml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Analytics dashboard configuration
|
||||
dashboard:
|
||||
title: "Website Analytics Dashboard"
|
||||
refresh_interval: 300 # seconds
|
||||
timezone: "UTC"
|
||||
|
||||
# Data sources
|
||||
data_sources:
|
||||
- name: "web_analytics"
|
||||
type: "google_analytics"
|
||||
enabled: true
|
||||
- name: "user_tracking"
|
||||
type: "internal"
|
||||
enabled: true
|
||||
- name: "conversion_tracking"
|
||||
type: "mixpanel"
|
||||
enabled: false
|
||||
|
||||
# Chart configurations
|
||||
charts:
|
||||
page_views:
|
||||
type: "line"
|
||||
time_range: "7d"
|
||||
color: "#3498db"
|
||||
bounce_rate:
|
||||
type: "gauge"
|
||||
threshold: 0.5
|
||||
color: "#e74c3c"
|
||||
conversion_funnel:
|
||||
type: "funnel"
|
||||
steps:
|
||||
- "landing_page"
|
||||
- "product_view"
|
||||
- "add_to_cart"
|
||||
- "checkout"
|
||||
- "purchase"
|
||||
|
||||
# Alerts
|
||||
alerts:
|
||||
high_bounce_rate:
|
||||
threshold: 0.6
|
||||
enabled: true
|
||||
notification_email: "admin@example.com"
|
||||
low_conversion:
|
||||
threshold: 0.02
|
||||
enabled: true
|
||||
notification_email: "marketing@example.com"
|
||||
|
||||
# Export settings
|
||||
export:
|
||||
formats: ["csv", "json", "pdf"]
|
||||
schedule: "daily"
|
||||
recipients: ["analytics@example.com"]
|
||||
60
essai/analytics/users.json
Normal file
60
essai/analytics/users.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "u001",
|
||||
"username": "alice_johnson",
|
||||
"email": "alice@example.com",
|
||||
"registration_date": "2024-03-15",
|
||||
"last_login": "2025-05-06T10:30:00Z",
|
||||
"profile": {
|
||||
"first_name": "Alice",
|
||||
"last_name": "Johnson",
|
||||
"age": 28,
|
||||
"location": "New York, USA"
|
||||
},
|
||||
"preferences": {
|
||||
"theme": "dark",
|
||||
"notifications": true,
|
||||
"language": "en"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "u002",
|
||||
"username": "bob_smith",
|
||||
"email": "bob@example.com",
|
||||
"registration_date": "2024-01-22",
|
||||
"last_login": "2025-05-05T14:45:00Z",
|
||||
"profile": {
|
||||
"first_name": "Bob",
|
||||
"last_name": "Smith",
|
||||
"age": 35,
|
||||
"location": "London, UK"
|
||||
},
|
||||
"preferences": {
|
||||
"theme": "light",
|
||||
"notifications": false,
|
||||
"language": "en"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "u003",
|
||||
"username": "maria_garcia",
|
||||
"email": "maria@example.com",
|
||||
"registration_date": "2024-07-08",
|
||||
"last_login": "2025-05-06T09:15:00Z",
|
||||
"profile": {
|
||||
"first_name": "Maria",
|
||||
"last_name": "Garcia",
|
||||
"age": 31,
|
||||
"location": "Barcelona, Spain"
|
||||
},
|
||||
"preferences": {
|
||||
"theme": "dark",
|
||||
"notifications": true,
|
||||
"language": "es"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_users": 3,
|
||||
"active_users_last_week": 2
|
||||
}
|
||||
16
essai/sample.json
Normal file
16
essai/sample.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "Sample JSON Data",
|
||||
"description": "This is a sample JSON file for the static file server",
|
||||
"items": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Item 4",
|
||||
"value": 30.25
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created": "2025-05-12",
|
||||
"version": "1.0",
|
||||
"author": "Flask Static Server"
|
||||
}
|
||||
}
|
||||
52
examples/basic_usage.py
Normal file
52
examples/basic_usage.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic usage example for dfdr.
|
||||
|
||||
This example demonstrates how to use dfdr programmatically.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from dfdr.config import Config
|
||||
from dfdr.storage import Storage
|
||||
|
||||
|
||||
def main():
|
||||
"""Demonstrate basic dfdr usage."""
|
||||
|
||||
# Create a temporary directory for this example
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
print(f"Working in temporary directory: {tmpdir}")
|
||||
|
||||
# Initialize configuration
|
||||
config = Config(Path(tmpdir))
|
||||
print("✓ Initialized dfdr configuration")
|
||||
|
||||
# Add a remote (this would be a real URL in practice)
|
||||
config.add_remote("example", "https://data.example.com/")
|
||||
print("✓ Added remote 'example'")
|
||||
|
||||
# List remotes
|
||||
remotes = config.list_remotes()
|
||||
print(f"✓ Configured remotes: {list(remotes.keys())}")
|
||||
|
||||
# Initialize storage
|
||||
storage = Storage(config)
|
||||
print("✓ Initialized storage manager")
|
||||
|
||||
# In a real scenario, you would:
|
||||
# 1. storage.add_file("example", "datasets/sales.csv")
|
||||
# 2. storage.fetch_all()
|
||||
# 3. storage.pull_all()
|
||||
# 4. status = storage.get_status()
|
||||
|
||||
print("\n📁 Directory structure created:")
|
||||
for path in sorted(Path(tmpdir).rglob("*")):
|
||||
if path.is_file():
|
||||
rel_path = path.relative_to(tmpdir)
|
||||
print(f" {rel_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
install.sh
Executable file
1
install.sh
Executable file
|
|
@ -0,0 +1 @@
|
|||
pip install -e .
|
||||
19
licence.txt
Normal file
19
licence.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2025 WhirlingAI (contact@whirlingai.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
165
readme.rst
Normal file
165
readme.rst
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
|
||||
A Python command-line tool for managing remote data registries, inspired by DVC and Git but focused specifically on data registry functionality.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
dfdr allows you to:
|
||||
|
||||
- Declare remote data sources (web servers serving CSV, JSON, YAML, TXT files over HTTP)
|
||||
- Add specific files from data registries to your working copy
|
||||
- Mirror data locally for efficient access
|
||||
- Track file changes with checksums
|
||||
- Keep your working copy synchronized with remote registries
|
||||
DefDer (Data Registry Definition) - dfdr
|
||||
|
||||
A Python command-line tool for managing local data registries, inspired by DVC and Git but focused specifically on data registry functionality.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
dfdr allows you to:
|
||||
|
||||
- Declare local data sources (folders containing CSV, JSON, YAML, TXT files)
|
||||
- Add specific files from data registries to your working copy
|
||||
- Mirror data locally for efficient access
|
||||
- Track file changes with checksums
|
||||
- Keep your working copy synchronized with local registries
|
||||
========================================
|
||||
|
||||
A Python command-line tool for managing remote data registries, inspired by DVC and Git but focused specifically on data registry functionality.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
dfdr allows you to:
|
||||
|
||||
- Declare remote data sources (web servers serving CSV, JSON, YAML, TXT files over HTTP)
|
||||
- Add specific files from data registries to your working copy
|
||||
- Mirror data locally for efficient access
|
||||
- Track file changes with checksums
|
||||
- Keep your working copy synchronized with remote registries
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install dfdr
|
||||
|
||||
Or install from source:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://defder.fr/dfdr.git
|
||||
cd dfdr
|
||||
pip install -e .
|
||||
|
||||
Quick Start
|
||||
-----------
|
||||
|
||||
First, run
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dfdr init
|
||||
|
||||
|
||||
1. Add a local data registry:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dfdr registry add myregistry /path/to/local/data/folder
|
||||
|
||||
2. Add files from the registry to your working copy:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dfdr add myregistry:datasets/sales.csv
|
||||
dfdr add myregistry:models/config.json
|
||||
|
||||
3. Update your local cache:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dfdr fetch
|
||||
|
||||
4. Check the status of your files:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dfdr status
|
||||
|
||||
5. Update your working copy:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dfdr pull
|
||||
|
||||
Commands
|
||||
--------
|
||||
|
||||
Registry Management
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``dfdr registry add <name> <path>``
|
||||
Add a local data registry
|
||||
|
||||
``dfdr registry list``
|
||||
List all configured registries
|
||||
|
||||
``dfdr registry remove <name>``
|
||||
Remove a local data registry
|
||||
|
||||
Data Management
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
``dfdr add <remote_name>:<file_path>``
|
||||
Add a specific file from a registry to your working copy
|
||||
|
||||
``dfdr fetch``
|
||||
Mirror all data to local ``.dfdr`` storage from all remotes
|
||||
|
||||
``dfdr pull [file_path]``
|
||||
Update working copy from cache (all files or specific file)
|
||||
|
||||
``dfdr status``
|
||||
Show sync status of files
|
||||
|
||||
Data Registry Structure
|
||||
-----------------------
|
||||
|
||||
Local data registries should follow this structure:
|
||||
|
||||
- Files are stored in local directories
|
||||
- No ``index.json`` file is required; the tool will automatically discover files in the directory
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
/path/to/registry/
|
||||
├── datasets/
|
||||
│ ├── sales.csv
|
||||
│ └── customers.json
|
||||
└── models/
|
||||
└── config.yaml
|
||||
|
||||
Local Storage
|
||||
-------------
|
||||
|
||||
dfdr creates a ``.dfdr`` directory in your project containing:
|
||||
|
||||
- ``config.json`` - Local registry configuration
|
||||
- ``storage/`` - Local mirror of registry data
|
||||
- ``*.dfdr`` files - MD5 checksums for each data file (e.g., ``sales.csv.dfdr``)
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
MIT License
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Core dependencies
|
||||
httpx>=0.24.0
|
||||
click>=8.0.0
|
||||
rich>=13.0.0
|
||||
pyyaml
|
||||
|
||||
# Development dependencies (optional)
|
||||
#pytest>=7.0.0
|
||||
#pytest-asyncio>=0.21.0
|
||||
#pytest-cov>=4.0.0
|
||||
47
setup.py
Normal file
47
setup.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup script for DefDer (Data Registry Definition) - dfdr
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
with open("README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
with open("requirements.txt", "r", encoding="utf-8") as fh:
|
||||
requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")]
|
||||
|
||||
setup(
|
||||
name="dfdr",
|
||||
version="0.1.0",
|
||||
author="DefDer Team",
|
||||
author_email="contact@defder.fr",
|
||||
description="Data Registry Definition tool for managing remote data sources",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/x-rst",
|
||||
url="https://github.com/defder/dfdr",
|
||||
packages=find_packages(),
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
python_requires=">=3.8",
|
||||
install_requires=requirements,
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"dfdr=dfdr.cli:main",
|
||||
],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Tests for dfdr.
|
||||
"""
|
||||
204
tests/test_basic.py
Normal file
204
tests/test_basic.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""
|
||||
Basic tests for dfdr functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from dfdr.config import Config
|
||||
from dfdr.checksum import calculate_md5, save_checksum, load_checksum, load_checksum_info
|
||||
from dfdr.exceptions import ConfigError, ChecksumError
|
||||
from dfdr.fetcher import Fetcher
|
||||
from dfdr.storage import Storage
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Test configuration management."""
|
||||
|
||||
def test_config_initialization(self):
|
||||
"""Test that config initializes correctly."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config = Config(Path(tmpdir))
|
||||
assert config.project_root == Path(tmpdir)
|
||||
assert config.dfdr_dir.exists()
|
||||
assert config.storage_dir.exists()
|
||||
|
||||
def test_add_remote(self):
|
||||
"""Test adding a local registry."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config = Config(Path(tmpdir))
|
||||
registry_path = Path(tmpdir) / "test_registry"
|
||||
registry_path.mkdir()
|
||||
config.add_remote("test", str(registry_path))
|
||||
|
||||
remotes = config.list_remotes()
|
||||
assert "test" in remotes
|
||||
assert remotes["test"] == registry_path
|
||||
|
||||
def test_duplicate_remote(self):
|
||||
"""Test that adding duplicate local registry raises error."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config = Config(Path(tmpdir))
|
||||
registry_path = Path(tmpdir) / "test_registry"
|
||||
registry_path.mkdir()
|
||||
config.add_remote("test", str(registry_path))
|
||||
|
||||
with pytest.raises(ConfigError):
|
||||
config.add_remote("test", str(Path(tmpdir) / "other_registry"))
|
||||
|
||||
def test_remove_remote(self):
|
||||
"""Test removing a local registry."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config = Config(Path(tmpdir))
|
||||
registry_path = Path(tmpdir) / "test_registry"
|
||||
registry_path.mkdir()
|
||||
config.add_remote("test", str(registry_path))
|
||||
config.remove_remote("test")
|
||||
|
||||
remotes = config.list_remotes()
|
||||
assert "test" not in remotes
|
||||
|
||||
def test_remove_nonexistent_remote(self):
|
||||
"""Test that removing nonexistent remote raises error."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config = Config(Path(tmpdir))
|
||||
|
||||
with pytest.raises(ConfigError):
|
||||
config.remove_remote("nonexistent")
|
||||
|
||||
|
||||
class TestChecksum:
|
||||
"""Test checksum functionality."""
|
||||
|
||||
def test_calculate_md5(self):
|
||||
"""Test MD5 calculation."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||
f.write("test content")
|
||||
f.flush()
|
||||
|
||||
checksum = calculate_md5(Path(f.name))
|
||||
# MD5 of "test content"
|
||||
expected = "9473fdd0d880a43c21b7778d34872157"
|
||||
assert checksum == expected
|
||||
assert len(checksum) == 32 # MD5 is 32 hex chars
|
||||
assert isinstance(checksum, str)
|
||||
|
||||
Path(f.name).unlink()
|
||||
|
||||
def test_save_and_load_checksum(self):
|
||||
"""Test saving and loading checksums."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
|
||||
# Calculate and save checksum
|
||||
checksum = calculate_md5(test_file)
|
||||
save_checksum(test_file, checksum, "test_registry", "test.txt")
|
||||
|
||||
# Load and verify
|
||||
loaded_info = load_checksum_info(test_file)
|
||||
assert loaded_info is not None
|
||||
assert loaded_info["checksum"] == checksum
|
||||
assert loaded_info["registry_name"] == "test_registry"
|
||||
assert loaded_info["original_path"] == "test.txt"
|
||||
|
||||
# Test the simple load_checksum function
|
||||
loaded_checksum = load_checksum(test_file)
|
||||
assert loaded_checksum == checksum
|
||||
|
||||
# Check that .dfdr file exists
|
||||
dfdr_file = Path(f"{test_file}.dfdr")
|
||||
assert dfdr_file.exists()
|
||||
|
||||
def test_load_nonexistent_checksum(self):
|
||||
"""Test loading checksum for nonexistent file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "nonexistent.txt"
|
||||
checksum = load_checksum(test_file)
|
||||
assert checksum is None
|
||||
|
||||
|
||||
class TestFetcher:
|
||||
"""Test Fetcher functionality."""
|
||||
|
||||
def test_fetch_index(self):
|
||||
"""Test fetching index from local registry."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
registry_path = Path(tmpdir) / "test_registry"
|
||||
registry_path.mkdir()
|
||||
(registry_path / "file1.txt").touch()
|
||||
(registry_path / "file2.txt").touch()
|
||||
|
||||
fetcher = Fetcher()
|
||||
files = fetcher.fetch_index(registry_path)
|
||||
|
||||
assert set(files) == {"file1.txt", "file2.txt"}
|
||||
|
||||
def test_fetch_file(self):
|
||||
"""Test fetching a file from local registry."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
registry_path = Path(tmpdir) / "test_registry"
|
||||
registry_path.mkdir()
|
||||
source_file = registry_path / "test_file.txt"
|
||||
source_file.write_text("test content")
|
||||
|
||||
output_path = Path(tmpdir) / "output.txt"
|
||||
|
||||
fetcher = Fetcher()
|
||||
fetcher.fetch_file(registry_path, "test_file.txt", output_path)
|
||||
|
||||
assert output_path.exists()
|
||||
assert output_path.read_text() == "test content"
|
||||
|
||||
|
||||
class TestStorage:
|
||||
"""Test Storage functionality."""
|
||||
|
||||
def test_add_file(self):
|
||||
"""Test adding a file from local registry to working copy."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_root = Path(tmpdir) / "project"
|
||||
project_root.mkdir()
|
||||
config = Config(project_root)
|
||||
|
||||
registry_path = Path(tmpdir) / "test_registry"
|
||||
registry_path.mkdir()
|
||||
source_file = registry_path / "test_file.txt"
|
||||
source_file.write_text("test content")
|
||||
|
||||
config.add_remote("test", str(registry_path))
|
||||
|
||||
storage = Storage(config)
|
||||
storage.add_file("test", "test_file.txt")
|
||||
|
||||
working_copy_file = project_root / "test_file.txt"
|
||||
assert working_copy_file.exists()
|
||||
assert working_copy_file.read_text() == "test content"
|
||||
|
||||
def test_fetch_all(self):
|
||||
"""Test fetching all files from local registries."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_root = Path(tmpdir) / "project"
|
||||
project_root.mkdir()
|
||||
config = Config(project_root)
|
||||
|
||||
registry1_path = Path(tmpdir) / "registry1"
|
||||
registry1_path.mkdir()
|
||||
(registry1_path / "file1.txt").write_text("content1")
|
||||
|
||||
registry2_path = Path(tmpdir) / "registry2"
|
||||
registry2_path.mkdir()
|
||||
(registry2_path / "file2.txt").write_text("content2")
|
||||
|
||||
config.add_remote("reg1", str(registry1_path))
|
||||
config.add_remote("reg2", str(registry2_path))
|
||||
|
||||
storage = Storage(config)
|
||||
fetched_files = storage.fetch_all()
|
||||
|
||||
assert set(fetched_files.keys()) == {"reg1", "reg2"}
|
||||
assert set(fetched_files["reg1"]) == {"file1.txt"}
|
||||
assert set(fetched_files["reg2"]) == {"file2.txt"}
|
||||
12
todo.txt
Normal file
12
todo.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
Todo
|
||||
--------
|
||||
|
||||
- clean the dependencies
|
||||
dans le requirements:
|
||||
|
||||
# Core dependencies
|
||||
httpx>=0.24.0 -> NON
|
||||
|
||||
|
||||
- add a **real** development and tests mode
|
||||
|
||||
Loading…
Reference in a new issue