diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index b58e9f4..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [vaibhavvikas] diff --git a/.gitignore b/.gitignore index fd52dc6..6016dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,10 @@ __pycache__/ *.py[cod] *$py.class - +*.md # C extensions *.so - +*.spec # Distribution / packaging .Python build/ @@ -162,3 +162,6 @@ cython_debug/ # Download file specific mods/ + +# Factorio Mod Downloader specific +.factorio-mod-downloader/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dc3ac7f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "factorio_mod_downloader_rust" +version = "0.3.0" +edition = "2021" + +[lints.rust] +dead_code = "allow" + +[lib] +name = "factorio_mod_downloader_rust" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.22", features = ["extension-module"] } +reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "cookies", "gzip", "brotli", "deflate", "rustls-tls", "native-tls", "socks"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +futures-util = "0.3" +indicatif = "0.18.3" +console = "0.16.1" +colored = "3.0.0" +clap = { version = "4.5", features = ["derive"] } diff --git a/README.md b/README.md index dd97ae7..a83b8a0 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ It is really helpful if you want to download a mod or modpack containing various ![Factorio Mod Downloader](factorio_mod_downloader.png) +## Features -### Features 1. Added Dark Mode 2. Added progress bars and logs to see what files are being downloaded. 3. Added a seperate downloads section to track each file with custom progress and success icons. @@ -24,37 +24,67 @@ It is really helpful if you want to download a mod or modpack containing various 6. Updated to add the option to downlaod optional dependencies as well (Use with caution as it may significantly increase number of files getting downloaded). 7. Completely interactive and requires no other dependency. 100% standalone app. - ### How to download -1. Go to [Releases](https://github.com/vaibhavvikas/factorio-mod-downloader/releases/latest) -2. Download the latest executable i.e. **\*.exe file** from the latest version added inside the assets dropdown. Latest release version is mentioned on the top of README.md file. +1. Go to [Releases](https://github.com/vaibhavvikas/factorio-mod-downloader/releases/latest) +2. Download the latest executable i.e. **\*.exe file** from the latest version added inside the assets dropdown. Latest release version is mentioned on the top of README.md file. ### How to run + 1. Run the app, select the directory and add mod url from official [factorio mod portal](https://mods.factorio.com/) for e.g. URL for Krastorio 2 mod is: `https://mods.factorio.com/mod/Krastorio2`. 2. Click on Download button. 3. The application will start downloading the mods and show the status and progress in the corresponding sections. 4. The first step of loading dependencies take some time as it download [chromium-drivers](https://github.com/yeongbin-jo/python-chromedriver-autoinstaller) (~30-35 MB) required for loading URLs and the mods for downloading. 5. Once completed the application will show a download complete dialog. - ### Development -1. You can build and run the app yourself. I have written the code in python and implemented poetry for dependency management and easy build. -2. Install python > v3.12 and install poetry, refer to [poetry official website](https://python-poetry.org/docs/#installation) for installation guide. -3. Install dependencies via the command `poetry install`. -4. To run the application use the command `poetry run factorio-mod-downloader`. This will run the application directly without building. -5. To build the application, I am using pyinstaller (you need a **Windows x64** system to build it). Run the command `poetry build` to build the application. A new .exe file will be generated inside `/dist/pyinstaller/win_amd64`. +#### Prerequisites + +Before you begin, ensure you have the following installed on your machine: + +1. **Python 3.12**(recommended) + - Download from [python.org](https://python.org/downloads/) + +2. **Rust (for building the performance extension)** + - Download from [rustup.rs](https://rustup.rs/) + +3. **Poetry (Python dependency manager)** + - Install via: `pip install poetry` + - Or follow [poetry official guide](https://python-poetry.org/docs/#installation) + - `poetry self add poetry-pyinstaller-plugin`(important to get the `.exe`) + +4. **Maturin (Rust-Python bridge)** + - Installed automatically by Poetry + - manually: `pip install maturin` + +#### Quick Start + +```bash +# Clone the repository +git clone https://github.com/vaibhavvikas/factorio-mod-downloader.git +cd factorio-mod-downloader + +# Install dependencies and build Rust extension +.\build.ps1 -BuildExe or -be # additionally builds the `.exe` available at `\dist\fmd-0.4.0.exe` make sure to use `deactivate` venv when your re-building. + +# Run the application (GUI mode) +poetry run python -m factorio_mod_downloader --gui + +# Run in CLI mode +poetry run python -m factorio_mod_downloader --help # `-hh` more info + +# you use the executable +fmd.exe | fmd.exe -h +``` ### Note -I have finally included optional dependencies as well. My advice is handle with care as it significantly increase the number and size of downloads. +I have finally included optional dependencies as well. My advice is handle with care as it significantly increase the number and size of downloads. Also, download speed is based on re146, Its not super fast but its fine. +Feel free to reach out to me or start a message in the discussions tab if you need some help. -Feel free to reach out to me or start a message in the discussions tab if you need some help. +### Credits - -### Credits: - re146.dev - [radioegor146](https://github.com/radioegor146) - diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..b0d3879 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,398 @@ +param( + [switch]$SkipTests, + [Alias("be")] + [switch]$BuildExe, + [switch]$KeepVenv, + [Alias("co")] + [switch]$CleanOnly, + [switch]$KeepFiles +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Step { param($msg) Write-Host "`n>> $msg" -ForegroundColor Cyan } +function Write-Success { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green } +function Write-Error { param($msg) Write-Host " [ERROR] $msg" -ForegroundColor Red } +function Write-Warning { param($msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow } +function Write-Info { param($msg) Write-Host " [INFO] $msg" -ForegroundColor Gray } + +# Calculate total steps based on flags +$totalSteps = 7 +if ($BuildExe) { $totalSteps = 8 } +if ($CleanOnly) { $totalSteps = 1 } + +Write-Host "===================================================================" -ForegroundColor Cyan +Write-Host " Build Script - Factorio Mod Downloader" -ForegroundColor Cyan +Write-Host "===================================================================" -ForegroundColor Cyan + +if ($CleanOnly) { + Write-Info "Clean-only mode: Will clean and exit" +} +if ($KeepFiles) { + Write-Info "Keep-files mode: Will preserve generated files during build" +} + +# =================================================================== +# Step 1: Clean all build artifacts +# =================================================================== +if (-not $KeepFiles) { + Write-Step "[1/$totalSteps] Cleaning build artifacts..." + + $foldersToRemove = @( + "dist", "build", "*.egg-info", ".eggs", "target", "__pycache__", + ".pytest_cache", ".mypy_cache", ".ruff_cache", + "src/factorio_mod_downloader/__pycache__", + "src/factorio_mod_downloader/cli/__pycache__", + "src/factorio_mod_downloader/core/__pycache__", + "src/factorio_mod_downloader/gui/__pycache__", + "src/factorio_mod_downloader/downloader/__pycache__", + "src/factorio_mod_downloader/infrastructure/__pycache__", + ".tox", ".nox", "htmlcov" + ) + + $filesToRemove = @( + # Compiled Python + "*.pyc", "*.pyo", "*.pyd", + + # Rust extension + "factorio_mod_downloader_rust.pyd", + "src/factorio_mod_downloader/factorio_mod_downloader_rust.pyd", + + # Lock files + "*.lock", + "Cargo.lock", + "poetry.lock", + "uv.lock", + + # Spec files + "*.spec", + + # Logs + "*.log", + + # OS files + ".DS_Store", "Thumbs.db", + + # Coverage + ".coverage", "coverage.xml" + ) + + $filesRemoved = 0 + $foldersRemoved = 0 + + foreach ($pattern in $foldersToRemove) { + $items = Get-ChildItem -Path . -Filter $pattern -Recurse -Directory -ErrorAction SilentlyContinue + foreach ($item in $items) { + Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + $foldersRemoved++ + } + } + + foreach ($pattern in $filesToRemove) { + $items = Get-ChildItem -Path . -Filter $pattern -Recurse -File -ErrorAction SilentlyContinue + foreach ($item in $items) { + Remove-Item -Path $item.FullName -Force -ErrorAction SilentlyContinue + $filesRemoved++ + } + } + + Write-Success "Cleaned $foldersRemoved folders and $filesRemoved files" +} else { + Write-Step "[1/$totalSteps] Skipping cleanup (KeepFiles mode)" + Write-Info "Preserving existing build artifacts" +} + +# Exit if CleanOnly mode +if ($CleanOnly) { + Write-Host "`n===================================================================" -ForegroundColor Green + Write-Host " Clean Complete!" -ForegroundColor Green + Write-Host "===================================================================" -ForegroundColor Green + Write-Host "`nCleaned project files. Ready for fresh build." -ForegroundColor Cyan + Write-Host "Run: .\build.ps1 -BuildExe" -ForegroundColor White + exit 0 +} + +# =================================================================== +# Step 2: Handle virtual environment +# =================================================================== +if (-not $KeepVenv) { + Write-Step "[2/$totalSteps] Removing and recreating virtual environment..." + + if (Test-Path ".venv") { + Write-Info "Removing existing .venv..." + + # Deactivate if active + if ($env:VIRTUAL_ENV) { + Write-Info "Deactivating current virtual environment..." + deactivate 2>$null + } + + # Wait a moment for processes to release + Start-Sleep -Milliseconds 500 + + # Try to remove + try { + Remove-Item -Path ".venv" -Recurse -Force -ErrorAction Stop + } catch { + Write-Warning "Could not remove .venv (may be in use)" + Write-Warning "Please close all terminals using this venv and try again" + Write-Warning "Or run: .\build.ps1 -KeepVenv -BuildExe" + exit 1 + } + } + + Write-Info "Creating fresh virtual environment with Python 3.12..." + # Use py launcher to explicitly select Python 3.12 + py -3.12 -m venv .venv + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to create virtual environment!" + Write-Error "Make sure Python 3.12 is installed: py -3.12 --version" + exit 1 + } + Write-Success "Virtual environment created (Python 3.12)" +} else { + Write-Step "[2/$totalSteps] Keeping existing virtual environment..." + + # Verify the venv is using Python 3.12 + if (Test-Path ".venv\Scripts\python.exe") { + $venvVersion = & .\.venv\Scripts\python.exe --version 2>&1 + Write-Warning "Using existing .venv ($venvVersion)" + + if ($venvVersion -notmatch "3\.12") { + Write-Warning "WARNING: venv is using $venvVersion, but project requires 3.12" + Write-Warning "Consider running without -KeepVenv to recreate with correct version" + } + } +} + +# =================================================================== +# Step 3: Activate virtual environment and upgrade tools +# =================================================================== +Write-Step "[3/$totalSteps] Activating virtual environment and upgrading tools..." + +& .\.venv\Scripts\Activate.ps1 + +# Verify we're using Python 3.12 +Write-Info "Verifying Python version..." +$pythonVersion = python --version 2>&1 +Write-Info "Active Python: $pythonVersion" + +if ($pythonVersion -notmatch "3\.12") { + Write-Error "Virtual environment is using wrong Python version: $pythonVersion" + Write-Error "Expected: Python 3.12.x" + Write-Error "Run without -KeepVenv to recreate with correct version" + exit 1 +} + +Write-Info "Upgrading pip, setuptools, wheel..." +python -m pip install --upgrade pip setuptools wheel --quiet +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to upgrade pip!" + exit 1 +} + +Write-Info "Installing build tools (maturin, poetry)..." +pip install maturin poetry --quiet +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install build tools!" + exit 1 +} + +Write-Success "Tools installed and upgraded" + +# =================================================================== +# Step 4: Install Poetry dependencies +# =================================================================== +Write-Step "[4/$totalSteps] Installing Poetry dependencies..." + +Write-Info "Clearing Poetry cache..." +$null = poetry cache clear pypi --all -n 2>&1 + +Write-Info "Installing dependencies..." +poetry install --no-root +if ($LASTEXITCODE -ne 0) { + Write-Warning "Poetry install had issues, but continuing..." +} + +Write-Success "Dependencies installed" + +# =================================================================== +# Step 5: Build Rust extension with Maturin +# =================================================================== +Write-Step "[5/$totalSteps] Building Rust extension with Maturin..." + +Write-Info "Compiling Rust code in release mode..." +poetry run maturin develop --release +if ($LASTEXITCODE -ne 0) { + Write-Error "Rust build failed!" + exit 1 +} + +Write-Info "Locating built Rust extension..." + +# Search recursively for the .pyd file +$rustExtSource = Get-ChildItem -Path ".\.venv\Lib\site-packages" -Recurse -File | + Where-Object { $_.Extension -eq ".pyd" -and $_.Name -match "factorio_mod_downloader_rust" } | + Select-Object -First 1 + +if ($rustExtSource) { + Write-Success "Found Rust extension!" + Write-Info " Location: $($rustExtSource.FullName)" + Write-Info " Size: $([math]::Round($rustExtSource.Length / 1MB, 2)) MB" + + $destPath = "src\factorio_mod_downloader\factorio_mod_downloader_rust.pyd" + Copy-Item $rustExtSource.FullName $destPath -Force + + if (Test-Path $destPath) { + Write-Success "Copied to package directory" + } else { + Write-Error "Failed to copy Rust extension!" + exit 1 + } +} else { + Write-Error "Rust extension (.pyd) not found in venv!" + exit 1 +} + +Write-Success "Rust module built successfully" + +# =================================================================== +# Step 6: Install the package +# =================================================================== +Write-Step "[6/$totalSteps] Installing the package..." + +poetry install +if ($LASTEXITCODE -ne 0) { + Write-Error "Package installation failed!" + exit 1 +} + +Write-Success "Package installed" + +# =================================================================== +# Step 7: Run tests +# =================================================================== +if (-not $SkipTests) { + Write-Step "[7/$totalSteps] Running tests..." + + Write-Info "Testing Rust module import..." + $testResult = python -c "import factorio_mod_downloader_rust; print('OK')" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Rust module import failed!" + Write-Host $testResult -ForegroundColor Red + exit 1 + } + Write-Success "Rust module import test passed" + + Write-Info "Testing package import..." + $testResult = python -c "from factorio_mod_downloader import __version__; print(f'v{__version__}')" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Package import failed!" + Write-Host $testResult -ForegroundColor Red + exit 1 + } + Write-Success "Package import test passed: $testResult" + + if (Test-Path "test_package.py") { + Write-Info "Running package structure test..." + poetry run python test_package.py + if ($LASTEXITCODE -eq 0) { + Write-Success "Package structure test passed" + } else { + Write-Warning "Package structure test had issues" + } + } + + Write-Success "All tests passed!" +} else { + Write-Step "[7/$totalSteps] Skipping tests (SkipTests flag used)" +} + +# =================================================================== +# Step 8: Build executable (only if -BuildExe flag is used) +# =================================================================== +if ($BuildExe) { + Write-Step "[8/$totalSteps] Building executable with PyInstaller..." + + Write-Info "Deactivating virtual environment before build..." + if ($env:VIRTUAL_ENV) { + deactivate 2>$null + } + + Write-Info "Running poetry build..." + poetry build + + if ($LASTEXITCODE -ne 0) { + Write-Error "Poetry build failed!" + exit 1 + } + + # Check if executable was created + $exePath = Get-ChildItem -Path "dist\pyinstaller\win_amd64" -Filter "fmd-*.exe" -File -ErrorAction SilentlyContinue | Select-Object -First 1 + + if ($exePath) { + $exeSize = [math]::Round($exePath.Length / 1MB, 2) + Write-Success "Executable built successfully!" + Write-Info " Name: $($exePath.Name)" + Write-Info " Size: $exeSize MB" + + # Move to dist root for easier distribution + $destPath = "dist\$($exePath.Name)" + Move-Item $exePath.FullName $destPath -Force + Write-Success "Moved to: dist\$($exePath.Name)" + + # Clean up empty pyinstaller folder structure (optional) + if ((Get-ChildItem "dist\pyinstaller\win_amd64" -ErrorAction SilentlyContinue).Count -eq 0) { + Remove-Item "dist\pyinstaller" -Recurse -Force -ErrorAction SilentlyContinue + Write-Info " Cleaned up empty pyinstaller folder" + } + } else { + Write-Warning "Executable not found in dist\pyinstaller\win_amd64\" + + # Check if wheel was created instead + $wheelPath = Get-ChildItem -Path "dist" -Filter "*.whl" -File -ErrorAction SilentlyContinue + if ($wheelPath) { + Write-Info "Wheel package created: $($wheelPath.Name)" + Write-Warning "PyInstaller executable not created - check poetry-pyinstaller-plugin config" + } + } +} + +# =================================================================== +# Summary +# =================================================================== +Write-Host "`n===================================================================" -ForegroundColor Green +Write-Host " Build Complete!" -ForegroundColor Green +Write-Host "===================================================================" -ForegroundColor Green + +if ($BuildExe) { + Write-Host "`nExecutable ready for distribution!" -ForegroundColor Cyan + $exePath = Get-ChildItem -Path "dist" -Filter "fmd-*.exe" -File -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($exePath) { + Write-Host " Location: $($exePath.FullName)" -ForegroundColor White + Write-Host " Size: $([math]::Round($exePath.Length / 1MB, 2)) MB" -ForegroundColor White + } +} else { + Write-Host "`nNext Steps:" -ForegroundColor Cyan + Write-Host " [1] Build executable: poetry build" -ForegroundColor White + Write-Host " Or run: .\build.ps1 -BuildExe" -ForegroundColor Gray + Write-Host "" + Write-Host " [2] Test CLI: poetry run python -m factorio_mod_downloader --help" -ForegroundColor White + Write-Host "" + Write-Host " [3] Test download:" -ForegroundColor White + Write-Host " poetry run python -m factorio_mod_downloader download MOD_URL -o ./test" -ForegroundColor Gray + Write-Host "" + Write-Host " [4] Test GUI: poetry run python -m factorio_mod_downloader --gui" -ForegroundColor White +} + +Write-Host "`nUsage Examples:" -ForegroundColor Yellow +Write-Host " .\build.ps1 # Full build with tests" -ForegroundColor Gray +Write-Host " .\build.ps1 -BuildExe # Build + create .exe" -ForegroundColor Gray +Write-Host " .\build.ps1 -SkipTests -BuildExe # Fast build to .exe" -ForegroundColor Gray +Write-Host " .\build.ps1 -KeepVenv -BuildExe # Reuse venv, build .exe" -ForegroundColor Gray +Write-Host " .\build.ps1 -CleanOnly # Clean project files only" -ForegroundColor Gray +Write-Host " .\build.ps1 -co # Same as -CleanOnly (alias)" -ForegroundColor Gray +Write-Host " .\build.ps1 -KeepFiles -BuildExe # Build without cleaning" -ForegroundColor Gray + +Write-Host "" \ No newline at end of file diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index c5f1366..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1014 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. - -[[package]] -name = "altgraph" -version = "0.17.4" -description = "Python graph (network) package" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, - {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, -] - -[[package]] -name = "astroid" -version = "3.3.5" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "astroid-3.3.5-py3-none-any.whl", hash = "sha256:a9d1c946ada25098d790e079ba2a1b112157278f3fb7e718ae6a9252f5835dc8"}, - {file = "astroid-3.3.5.tar.gz", hash = "sha256:5cfc40ae9f68311075d27ef68a4841bdc5cc7f6cf86671b49f00607d30188e2d"}, -] - -[[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] - -[[package]] -name = "beautifulsoup4" -version = "4.12.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -groups = ["main"] -files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "black" -version = "24.10.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "certifi" -version = "2024.8.30" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, -] - -[[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "os_name == \"nt\" and implementation_name != \"pypy\"" -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "charset-normalizer" -version = "3.4.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, -] - -[[package]] -name = "chromedriver-autoinstaller" -version = "0.6.4" -description = "Automatically install chromedriver that supports the currently installed version of chrome." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "chromedriver-autoinstaller-0.6.4.tar.gz", hash = "sha256:1b4df04b87e6107c730085b98e5fd541db3d1777c32b8bd08e2ca4b1244050af"}, - {file = "chromedriver_autoinstaller-0.6.4-py3-none-any.whl", hash = "sha256:b12ed187ca9fac4d744deb588d221222ed50836384607e5303e6eab98bb9dc64"}, -] - -[package.dependencies] -packaging = ">=23.1" - -[[package]] -name = "click" -version = "8.3.0" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, - {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "ctkmessagebox" -version = "2.7" -description = "A modern messagebox for customtkinter" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "CTkMessagebox-2.7-py3-none-any.whl", hash = "sha256:68941ccab2251f2fd3ac3338a99bdd30d9f28fee4ef0070dfdd0420be1c34a5a"}, - {file = "ctkmessagebox-2.7.tar.gz", hash = "sha256:5ce7e76b797c8d5de5f29438d8c784e33c72fb6ff666c87eb8991815c46c59c5"}, -] - -[package.dependencies] -customtkinter = "*" -pillow = "*" - -[[package]] -name = "customtkinter" -version = "5.2.2" -description = "Create modern looking GUIs with Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "customtkinter-5.2.2-py3-none-any.whl", hash = "sha256:14ad3e7cd3cb3b9eb642b9d4e8711ae80d3f79fb82545ad11258eeffb2e6b37c"}, - {file = "customtkinter-5.2.2.tar.gz", hash = "sha256:fd8db3bafa961c982ee6030dba80b4c2e25858630756b513986db19113d8d207"}, -] - -[package.dependencies] -darkdetect = "*" -packaging = "*" - -[[package]] -name = "darkdetect" -version = "0.8.0" -description = "Detect OS Dark Mode from Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85"}, - {file = "darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1"}, -] - -[package.extras] -macos-listener = ["pyobjc-framework-Cocoa ; platform_system == \"Darwin\""] - -[[package]] -name = "dill" -version = "0.3.9" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"}, - {file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, -] - -[package.extras] -colors = ["colorama (>=0.4.6)"] - -[[package]] -name = "macholib" -version = "1.16.3" -description = "Mach-O header analysis and editing" -optional = false -python-versions = "*" -groups = ["dev"] -markers = "sys_platform == \"darwin\"" -files = [ - {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, - {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, -] - -[package.dependencies] -altgraph = ">=0.17" - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "outcome" -version = "1.3.0.post0" -description = "Capture the outcome of Python function calls." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, - {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, -] - -[package.dependencies] -attrs = ">=19.2.0" - -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pefile" -version = "2023.2.7" -description = "Python PE parsing module" -optional = false -python-versions = ">=3.6.0" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, - {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, -] - -[[package]] -name = "pillow" -version = "11.0.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, - {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, - {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, - {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, - {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, - {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, - {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, - {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, - {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, - {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, - {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, - {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, - {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, - {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, - {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, - {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, - {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, - {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, - {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, - {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, - {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, - {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions ; python_version < \"3.10\""] -xmp = ["defusedxml"] - -[[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "poetry-pyinstaller-plugin" -version = "1.4.0" -description = "Poetry plugin to build and/or bundle executable binaries with PyInstaller" -optional = false -python-versions = "<3.14,>=3.9" -groups = ["main"] -files = [ - {file = "poetry_pyinstaller_plugin-1.4.0-py3-none-any.whl", hash = "sha256:073cb78b5626328736a3c8b167cc093f8b9e37bb824394f124e289bc6b5ed39c"}, - {file = "poetry_pyinstaller_plugin-1.4.0.tar.gz", hash = "sha256:e6fca31b8abc947baa2b241bee2387fcc70df42ab1d06e2ccd651f12288104bd"}, -] - -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "os_name == \"nt\" and implementation_name != \"pypy\"" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pyinstaller" -version = "6.13.0" -description = "PyInstaller bundles a Python application and all its dependencies into a single package." -optional = false -python-versions = "<3.14,>=3.8" -groups = ["dev"] -files = [ - {file = "pyinstaller-6.13.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:aa404f0b02cd57948098055e76ee190b8e65ccf7a2a3f048e5000f668317069f"}, - {file = "pyinstaller-6.13.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:92efcf2f09e78f07b568c5cb7ed48c9940f5dad627af4b49bede6320fab2a06e"}, - {file = "pyinstaller-6.13.0-py3-none-manylinux2014_i686.whl", hash = "sha256:9f82f113c463f012faa0e323d952ca30a6f922685d9636e754bd3a256c7ed200"}, - {file = "pyinstaller-6.13.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:db0e7945ebe276f604eb7c36e536479556ab32853412095e19172a5ec8fca1c5"}, - {file = "pyinstaller-6.13.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:92fe7337c5aa08d42b38d7a79614492cb571489f2cb0a8f91dc9ef9ccbe01ed3"}, - {file = "pyinstaller-6.13.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:bc09795f5954135dd4486c1535650958c8218acb954f43860e4b05fb515a21c0"}, - {file = "pyinstaller-6.13.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:589937548d34978c568cfdc39f31cf386f45202bc27fdb8facb989c79dfb4c02"}, - {file = "pyinstaller-6.13.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b7260832f7501ba1d2ce1834d4cddc0f2b94315282bc89c59333433715015447"}, - {file = "pyinstaller-6.13.0-py3-none-win32.whl", hash = "sha256:80c568848529635aa7ca46d8d525f68486d53e03f68b7bb5eba2c88d742e302c"}, - {file = "pyinstaller-6.13.0-py3-none-win_amd64.whl", hash = "sha256:8d4296236b85aae570379488c2da833b28828b17c57c2cc21fccd7e3811fe372"}, - {file = "pyinstaller-6.13.0-py3-none-win_arm64.whl", hash = "sha256:d9f21d56ca2443aa6a1e255e7ad285c76453893a454105abe1b4d45e92bb9a20"}, - {file = "pyinstaller-6.13.0.tar.gz", hash = "sha256:38911feec2c5e215e5159a7e66fdb12400168bd116143b54a8a7a37f08733456"}, -] - -[package.dependencies] -altgraph = "*" -macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} -packaging = ">=22.0" -pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2025.2" -pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} -setuptools = ">=42.0.0" - -[package.extras] -completion = ["argcomplete"] -hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] - -[[package]] -name = "pyinstaller-hooks-contrib" -version = "2025.4" -description = "Community maintained hooks for PyInstaller" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pyinstaller_hooks_contrib-2025.4-py3-none-any.whl", hash = "sha256:6c2d73269b4c484eb40051fc1acee0beb113c2cfb3b37437b8394faae6f0d072"}, - {file = "pyinstaller_hooks_contrib-2025.4.tar.gz", hash = "sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446"}, -] - -[package.dependencies] -packaging = ">=22.0" -setuptools = ">=42.0.0" - -[[package]] -name = "pylint" -version = "3.3.1" -description = "python code static checker" -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "pylint-3.3.1-py3-none-any.whl", hash = "sha256:2f846a466dd023513240bc140ad2dd73bfc080a5d85a710afdb728c420a5a2b9"}, - {file = "pylint-3.3.1.tar.gz", hash = "sha256:9f3dcc87b1203e612b78d91a896407787e708b3f189b5fa0b307712d49ff0c6e"}, -] - -[package.dependencies] -astroid = ">=3.3.4,<=3.4.0-dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pysocks" -version = "1.7.1" -description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, - {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, - {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, -] - -[[package]] -name = "pywin32-ctypes" -version = "0.3.0" -description = "A (partial) reimplementation of pywin32 using ctypes/cffi" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "pywin32-ctypes-0.3.0.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, - {file = "pywin32_ctypes-0.3.0-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, -] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "selenium" -version = "4.25.0" -description = "Official Python bindings for Selenium WebDriver" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "selenium-4.25.0-py3-none-any.whl", hash = "sha256:3798d2d12b4a570bc5790163ba57fef10b2afee958bf1d80f2a3cf07c4141f33"}, - {file = "selenium-4.25.0.tar.gz", hash = "sha256:95d08d3b82fb353f3c474895154516604c7f0e6a9a565ae6498ef36c9bac6921"}, -] - -[package.dependencies] -certifi = ">=2021.10.8" -trio = ">=0.17,<1.0" -trio-websocket = ">=0.9,<1.0" -typing_extensions = ">=4.9,<5.0" -urllib3 = {version = ">=1.26,<3", extras = ["socks"]} -websocket-client = ">=1.8,<2.0" - -[[package]] -name = "setuptools" -version = "80.8.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "setuptools-80.8.0-py3-none-any.whl", hash = "sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0"}, - {file = "setuptools-80.8.0.tar.gz", hash = "sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, - {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, -] - -[[package]] -name = "soupsieve" -version = "2.6" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, - {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, -] - -[[package]] -name = "tomlkit" -version = "0.13.2" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, - {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, -] - -[[package]] -name = "trio" -version = "0.27.0" -description = "A friendly Python library for async concurrency and I/O" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "trio-0.27.0-py3-none-any.whl", hash = "sha256:68eabbcf8f457d925df62da780eff15ff5dc68fd6b367e2dde59f7aaf2a0b884"}, - {file = "trio-0.27.0.tar.gz", hash = "sha256:1dcc95ab1726b2da054afea8fd761af74bad79bd52381b84eae408e983c76831"}, -] - -[package.dependencies] -attrs = ">=23.2.0" -cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} -idna = "*" -outcome = "*" -sniffio = ">=1.3.0" -sortedcontainers = "*" - -[[package]] -name = "trio-websocket" -version = "0.11.1" -description = "WebSocket library for Trio" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f"}, - {file = "trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638"}, -] - -[package.dependencies] -trio = ">=0.11" -wsproto = ">=0.14" - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.dependencies] -pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} - -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "websocket-client" -version = "1.8.0" -description = "WebSocket client for Python with low level API options" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, - {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, -] - -[package.extras] -docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] -optional = ["python-socks", "wsaccel"] -test = ["websockets"] - -[[package]] -name = "wsproto" -version = "1.2.0" -description = "WebSockets state-machine based protocol implementation" -optional = false -python-versions = ">=3.7.0" -groups = ["main"] -files = [ - {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, - {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, -] - -[package.dependencies] -h11 = ">=0.9.0,<1" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.12,<3.14" -content-hash = "f0a19de81670b7f291969ab93b521601132b6913f8c9816a0b72d61c5d8f2f0f" diff --git a/pyproject.toml b/pyproject.toml index df7487e..3c6c2ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,30 +1,41 @@ [tool.poetry] -name = "factorio-mod-downloader" +name = "fmd" version = "0.3.0" -description = "One Downloader for all your factorio mods." +description = "Download Factorio mods from the command line or GUI with full dependency resolution." authors = ["Vaibhav Vikas "] license = "MIT" readme = "README.md" classifiers = [ "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: X11 Applications", + "Intended Audience :: End Users/Desktop", + "Topic :: Games/Entertainment", ] packages = [ { include = "factorio_mod_downloader", from="src" }, ] +include = [ + { path = "src/factorio_mod_downloader/factorio_mod_downloader_rust.pyd", format = ["sdist", "wheel"] }, +] + [tool.poetry.urls] Changelog = "https://github.com/vaibhav_vikas/factorio-mod-downloader/releases" [tool.poetry.dependencies] -python = ">=3.12,<3.14" +python = ">=3.12,<3.13" requests = "^2.32.3" +beautifulsoup4 = "^4.12.3" +selenium = "^4.25.0" +chromedriver-autoinstaller = "^0.6.4" pillow = "^11.0.0" customtkinter = "^5.2.2" ctkmessagebox = "^2.7" -chromedriver-autoinstaller = "^0.6.4" -selenium = "^4.25.0" +pyyaml = "^6.0.2" +rich = "^13.9.4" +colorama = "^0.4.6" poetry-pyinstaller-plugin = "^1.4.0" -beautifulsoup4 = "^4.12.3" [tool.poetry.scripts] factorio-mod-downloader = "factorio_mod_downloader.__main__:main" @@ -34,6 +45,7 @@ pylint = "^3.3.1" pyinstaller = "^6.13.0" isort = ">=5.10.1" black = "^24.10.0" +maturin = "^1.7.0" [tool.isort] profile = "black" @@ -59,14 +71,31 @@ recursive-copy-metadata = [ "requests", "chromedriver_autoinstaller", "selenium", - "customtkinter" + "customtkinter", + "rich", + "colorama", ] -[tool.poetry-pyinstaller-plugin.scripts] -factorio_mod_downloader = { source = "src/factorio_mod_downloader/__main__.py", type = "onefile", bundle = true, add_version = true, icon = "factorio_downloader.ico", windowed = true } - +[tool.poetry-pyinstaller-plugin.scripts.fmd] +source = "src/factorio_mod_downloader/__main__.py" +type = "onefile" +bundle = true +add_version = true +icon = "factorio_downloader.ico" +console = true [tool.poetry-pyinstaller-plugin.collect] -all = ['factorio_mod_downloader', 'mod_downloader'] +all = ['factorio_mod_downloader', 'tkinter'] [tool.poetry-pyinstaller-plugin.include] "factorio_downloader.ico" = "." + +[tool.poetry-pyinstaller-plugin.hidden-imports] +hidden-imports = [ + "factorio_mod_downloader_rust", + "tkinter", + "tkinter.ttk", + "tkinter.constants", + "tkinter.messagebox", + "tkinter.filedialog", + "_tkinter", +] \ No newline at end of file diff --git a/src/batch_download.rs b/src/batch_download.rs new file mode 100644 index 0000000..853474b --- /dev/null +++ b/src/batch_download.rs @@ -0,0 +1,302 @@ +use crate::shared::*; +pub use crate::DownloadResult; +use std::collections::HashSet; +use std::time::Instant; +use indicatif::{ProgressBar, ProgressStyle, MultiProgress}; +use console::style; +use pyo3::prelude::*; +use serde_json::Value; + +// BatchFile structure for parsing JSON batch files +#[derive(Debug, serde::Deserialize)] +pub struct BatchFile { + pub name: Option, + pub description: Option, + pub version: Option, + pub author: Option, + pub mods: Vec, +} + +pub async fn batch_download_mods_enhanced( + mod_urls: Vec, + output_path: String, + factorio_version: String, + include_optional: bool, + include_optional_all: bool, + max_depth: usize, + continue_on_error: bool, +) -> Result> { + let start_time = Instant::now(); + + if mod_urls.is_empty() { + return Ok(DownloadResult { + success: false, + downloaded_mods: vec![], + failed_mods: vec![("batch".to_string(), "No mod URLs provided".to_string())], + total_size: 0, + duration: 0.0, + }); + } + + println!("{}", style(format!("Target Factorio version: {}", factorio_version)).dim()); + println!("{}", style(format!("Processing {} mod URLs from batch", mod_urls.len())).dim()); + + // === RESOLVING PHASE === + let resolve_start = Instant::now(); + let multi = MultiProgress::new(); + + let resolve_pb = multi.add(ProgressBar::new_spinner()); + resolve_pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + ); + resolve_pb.enable_steady_tick(std::time::Duration::from_millis(80)); + resolve_pb.set_message("🔍 Resolving dependencies for batch..."); + + let config = Config { + target_factorio_version: factorio_version.clone(), + target_mod_version: None, + install_optional_deps: include_optional, + install_optional_all_deps: include_optional_all, + max_depth, + }; + + let mut all_plans = Vec::new(); + let mut resolve_errors = Vec::new(); + + for mod_url in &mod_urls { + match extract_mod_id(mod_url) { + Ok(mod_id) => { + let mut visited = HashSet::new(); + match resolve_dependencies(&mod_id, &config, &mut visited, 0, false).await { + Ok(plans) => all_plans.extend(plans), + Err(e) => resolve_errors.push((mod_url.clone(), e.to_string())), + } + } + Err(e) => resolve_errors.push((mod_url.clone(), e.to_string())), + } + } + + let mut unique_plan: Vec = Vec::new(); + let mut seen_mods: HashSet = HashSet::new(); + + for plan in all_plans { + if !seen_mods.contains(&plan.mod_name) { + seen_mods.insert(plan.mod_name.clone()); + unique_plan.push(plan); + } + } + + let resolve_time = resolve_start.elapsed(); + resolve_pb.finish_and_clear(); + + if unique_plan.is_empty() && resolve_errors.is_empty() { + println!("{}", style("No compatible mods found in batch").red().bold()); + return Ok(DownloadResult { + success: false, + downloaded_mods: vec![], + failed_mods: vec![("batch".to_string(), "No compatible mods found".to_string())], + total_size: 0, + duration: start_time.elapsed().as_secs_f64(), + }); + } + + println!("{} {} in {:.2}s", + style("✅ Resolved").bold().green(), + style(format!("{} packages", unique_plan.len())).cyan().bold(), + resolve_time.as_secs_f64() + ); + + if !resolve_errors.is_empty() { + println!("{} {} resolution errors", + style("⚠").yellow().bold(), + style(resolve_errors.len()).yellow().bold() + ); + } + + // === DOWNLOADING PHASE === + let download_start = Instant::now(); + let download_pb = multi.add(ProgressBar::new(unique_plan.len() as u64)); + download_pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg} {bytes}/{total_bytes} ({bytes_per_sec}, {eta})") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + .progress_chars("█░") + ); + download_pb.enable_steady_tick(std::time::Duration::from_millis(80)); + + let mut stats = DownloadStats { + installed: Vec::new(), + failed: Vec::new(), + }; + + for (url, error) in resolve_errors { + stats.failed.push((url, error)); + } + + let mut total_bytes = 0u64; + + for (i, plan) in unique_plan.iter().enumerate() { + download_pb.set_message(format!("📦 Downloading {}", style(&plan.mod_name).cyan().bold())); + + match download_mod(&plan.mod_name, &plan.version, &plan.file_name, &output_path).await { + Ok(size) => { + stats.installed.push((plan.mod_name.clone(), plan.version.clone())); + total_bytes += size; + download_pb.set_length(total_bytes); + download_pb.set_position((i + 1) as u64 * total_bytes / unique_plan.len() as u64); + } + Err(e) => { + stats.failed.push((plan.mod_name.clone(), e.to_string())); + if !continue_on_error { + break; + } + } + } + + download_pb.inc(1); + } + + let download_time = download_start.elapsed(); + download_pb.finish_and_clear(); + + // === INSTALLING PHASE === + let install_pb = multi.add(ProgressBar::new_spinner()); + install_pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + ); + install_pb.enable_steady_tick(std::time::Duration::from_millis(80)); + install_pb.set_message("🔧 Installing packages..."); + + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + let install_time = download_start.elapsed(); + install_pb.finish_and_clear(); + + // === SUMMARY === + println!("{} {} in {:.0}ms ({:.2} MB/s)", + style("📥 Downloaded").bold().green(), + style(format!("{} packages", stats.installed.len())).cyan().bold(), + download_time.as_millis(), + if download_time.as_secs_f64() > 0.0 { + (total_bytes as f64 / 1024.0 / 1024.0) / download_time.as_secs_f64() + } else { 0.0 } + ); + + println!("{} {} in {:.0}ms", + style("🔧 Installed").bold().green(), + style(format!("{} packages", stats.installed.len())).cyan().bold(), + install_time.as_millis() + ); + + if !stats.installed.is_empty() { + println!("\n{} Installed packages:", style("📦").green().bold()); + for (name, version) in &stats.installed { + println!(" {} {}=={}", + style("+").green().bold(), + style(name).white().bold(), + style(version).dim() + ); + } + } + + if !stats.failed.is_empty() { + println!("\n{} Failed downloads:", style("⚠").yellow().bold()); + for (name, error) in &stats.failed { + println!(" {} {} - {}", + style("-").red().bold(), + style(name).white().bold(), + style(error).red().dim() + ); + } + } + + println!("\n{} Batch Summary:", style("📊").blue().bold()); + println!(" • Total URLs processed: {}", style(mod_urls.len()).cyan().bold()); + println!(" • Unique packages resolved: {}", style(unique_plan.len()).cyan().bold()); + println!(" • Successfully installed: {}", style(stats.installed.len()).green().bold()); + println!(" • Failed: {}", style(stats.failed.len()).red().bold()); + + if total_bytes > 0 { + println!(" • Total downloaded: {} ({:.2} MB/s average)", + style(format!("{:.2} MB", total_bytes as f64 / 1024.0 / 1024.0)).cyan().bold(), + if download_time.as_secs_f64() > 0.0 { + (total_bytes as f64 / 1024.0 / 1024.0) / download_time.as_secs_f64() + } else { 0.0 } + ); + } + + Ok(DownloadResult { + success: stats.failed.is_empty(), + downloaded_mods: stats.installed.iter().map(|(name, _)| name.clone()).collect(), + failed_mods: stats.failed, + total_size: total_bytes, + duration: start_time.elapsed().as_secs_f64(), + }) +} + +/// Parse batch file JSON - supports both structured format and simple URL arrays +pub fn parse_batch_file(json_content: &str) -> Result, Box> { + // Try structured batch file first + if let Ok(batch_file) = serde_json::from_str::(json_content) { + return Ok(batch_file.mods); + } + + // Fallback: simple array of strings + if let Ok(urls) = serde_json::from_str::>(json_content) { + return Ok(urls); + } + + // Last resort: generic JSON with mods field + if let Ok(value) = serde_json::from_str::(json_content) { + if let Some(mods) = value.get("mods") { + if let Some(mods_array) = mods.as_array() { + let urls: Result, _> = mods_array + .iter() + .map(|v| v.as_str().ok_or("Invalid mod URL").map(|s| s.to_string())) + .collect(); + return urls.map_err(|e| e.into()); + } + } + } + + Err("Could not parse batch file - expected JSON with 'mods' array or simple array of URLs".into()) +} + +// PyO3 wrapper for parse_batch_file +#[pyfunction(name = "parse_batch_file")] +pub fn parse_batch_file_py(json_content: &str) -> PyResult> { + parse_batch_file(json_content) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Parse error: {}", e))) +} + +// PyO3 wrapper for batch_download_mods_enhanced +#[pyfunction(name = "batch_download_mods_enhanced")] +#[pyo3(signature = (mod_urls, output_path, factorio_version="2.0", include_optional=true, include_optional_all=false, max_depth=10, continue_on_error=true))] +pub fn batch_download_mods_enhanced_py( + mod_urls: Vec, + output_path: String, + factorio_version: &str, + include_optional: bool, + include_optional_all: bool, + max_depth: usize, + continue_on_error: bool, +) -> PyResult { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime.block_on(batch_download_mods_enhanced( + mod_urls, + output_path, + factorio_version.to_string(), + include_optional, + include_optional_all, + max_depth, + continue_on_error, + )).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Batch download error: {}", e))) +} \ No newline at end of file diff --git a/src/factorio_mod_downloader/__init__.py b/src/factorio_mod_downloader/__init__.py index 67c7eaa..f7bb1e0 100644 --- a/src/factorio_mod_downloader/__init__.py +++ b/src/factorio_mod_downloader/__init__.py @@ -1 +1,37 @@ """Factorio Mod Downloader.""" + +__version__ = "0.3.0" + +# Import the Rust extension +try: + from . import factorio_mod_downloader_rust + + # Re-export main functions for easier access + from .factorio_mod_downloader_rust import ( + DownloadResult, + download_mod_with_deps, + batch_download_mods, + update_mod_list_json, + ) + + __all__ = [ + 'factorio_mod_downloader_rust', + 'DownloadResult', + 'download_mod_with_deps', + 'batch_download_mods', + 'update_mod_list_json', + ] + + # Flag to indicate Rust extension is available + RUST_AVAILABLE = True + +except ImportError as e: + import warnings + warnings.warn( + f"Rust extension not available: {e}\n" + "Some features may be slower. Run 'maturin develop' to build the Rust extension.", + RuntimeWarning + ) + + RUST_AVAILABLE = False + __all__ = [] \ No newline at end of file diff --git a/src/factorio_mod_downloader/__main__.py b/src/factorio_mod_downloader/__main__.py index 30b1ea6..bd7296e 100644 --- a/src/factorio_mod_downloader/__main__.py +++ b/src/factorio_mod_downloader/__main__.py @@ -1,15 +1,70 @@ """ Factorio Mod Downloader - Entry Point + +Routes to CLI or GUI based on command-line arguments. """ -from factorio_mod_downloader.gui.app import App +import sys +from typing import List + + +def should_use_cli(args: List[str]) -> bool: + """Determine if CLI mode should be used based on arguments. + + Args: + args: Command-line arguments (excluding program name). + + Returns: + True if CLI mode should be used, False for GUI mode. + """ + # No arguments means GUI mode + if not args: + return False + + # Explicit GUI flag + if '--gui' in args: + return False + + # Any other arguments mean CLI mode + return True def main(): - """Initialize and launch the application.""" - app = App() - app.mainloop() + """Initialize and launch the application (CLI or GUI).""" + args = sys.argv[1:] + + if should_use_cli(args): + # CLI mode - import CLI dependencies only when needed + from factorio_mod_downloader.cli.app import cli_main + sys.exit(cli_main(args)) + else: + # GUI mode - import GUI dependencies + # Hide console only after GUI import succeeds + try: + from factorio_mod_downloader.gui.app import App + + # Try to hide console on Windows (non-critical) + try: + import platform + if platform.system() == 'Windows': + from ctypes import windll + hwnd = windll.kernel32.GetConsoleWindow() + if hwnd != 0: + windll.user32.ShowWindow(hwnd, 0) + except: + pass # Silently ignore if hiding fails + + app = App() + app.mainloop() + except ImportError as e: + print(f"Error: Failed to load GUI components: {e}") + print("\nPossible causes:") + print(" - tkinter is not installed with Python") + print(" - customtkinter is not installed") + print("\nTry running in CLI mode instead:") + print(" fmd --help") + sys.exit(1) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/factorio_mod_downloader/cli/__init__.py b/src/factorio_mod_downloader/cli/__init__.py new file mode 100644 index 0000000..1600bec --- /dev/null +++ b/src/factorio_mod_downloader/cli/__init__.py @@ -0,0 +1,26 @@ +"""CLI module for Factorio Mod Downloader.""" + +from factorio_mod_downloader.cli.commands import ConfigCommands +from factorio_mod_downloader.cli.parser import create_parser, parse_args +from factorio_mod_downloader.cli.validators import ( + ValidationError, + validate_mod_url, + validate_file_path, + validate_directory_path, + validate_batch_file, + validate_positive_integer, + validate_and_raise +) + +__all__ = [ + 'ConfigCommands', + 'create_parser', + 'parse_args', + 'ValidationError', + 'validate_mod_url', + 'validate_file_path', + 'validate_directory_path', + 'validate_batch_file', + 'validate_positive_integer', + 'validate_and_raise' +] diff --git a/src/factorio_mod_downloader/cli/app.py b/src/factorio_mod_downloader/cli/app.py new file mode 100644 index 0000000..b7b0187 --- /dev/null +++ b/src/factorio_mod_downloader/cli/app.py @@ -0,0 +1,1384 @@ +"""CLI application core for Factorio Mod Downloader.""" + +import argparse +import os +import sys +import time +import zipfile +from pathlib import Path +from typing import List, Optional + +from factorio_mod_downloader.cli.output import OutputFormatter +from factorio_mod_downloader.cli.validators import ( + validate_mod_url, + validate_batch_file, + validate_directory_path +) +from factorio_mod_downloader.cli.error_formatter import ( + format_error_for_cli, + format_multiple_errors +) +from factorio_mod_downloader.infrastructure.errors import ( + ValidationError, + ModDownloaderError, + get_error_suggestion +) +from factorio_mod_downloader.core.downloader import CoreDownloader +from factorio_mod_downloader.core.mod_info_fetcher import ModInfoFetcher +from factorio_mod_downloader.core.rust_downloader import RustDownloader, RUST_AVAILABLE +from factorio_mod_downloader.infrastructure.config import Config, ConfigManager +from factorio_mod_downloader.infrastructure.logger import LoggerSystem +from factorio_mod_downloader.infrastructure.registry import ModRegistry + + +class CLIApp: + """Main CLI application coordinator. + + Handles command dispatch and orchestrates the interaction between + configuration, logging, output formatting, and core download functionality. + """ + + def __init__(self, config: Config, logger: LoggerSystem): + """Initialize CLIApp. + + Args: + config: Configuration object with application settings. + logger: LoggerSystem instance for logging. + """ + self.config = config + self.logger = logger + self.output = OutputFormatter(config) + self.config_manager = ConfigManager() + self.registry = ModRegistry() + + # Initialize Rust downloader if available + self.use_rust = RUST_AVAILABLE + if self.use_rust: + try: + self.rust_downloader = RustDownloader(logger, config) + self.logger.debug("Rust downloader initialized successfully") + except Exception as e: + self.logger.warning(f"Failed to initialize Rust downloader: {e}") + self.use_rust = False + else: + self.logger.info("Rust downloader not available, using Python fallback") + + def run(self, args: argparse.Namespace) -> int: + """Execute CLI command and return exit code. + + Args: + args: Parsed command-line arguments. + + Returns: + Exit code (0 for success, non-zero for failure). + """ + try: + # Dispatch to appropriate command handler + command = args.command + + if command == 'download': + return self.download_single(args) + elif command == 'batch': + return self.download_batch(args) + elif command == 'check-updates': + return self.check_updates(args) + elif command == 'update': + return self.update_mods(args) + elif command == 'config': + return self.handle_config(args) + elif command == 'validate': + return self.validate(args) + else: + self.output.print_error(f"Unknown command: {command}") + return 1 + + except KeyboardInterrupt: + self.output.print_warning("\nOperation cancelled by user") + return 130 # Standard exit code for SIGINT + except Exception as e: + self.logger.error(f"Unexpected error: {e}", exc_info=True) + self.output.print_error(f"Unexpected error: {e}") + return 1 + + def handle_config(self, args: argparse.Namespace) -> int: + """Handle config subcommands. + + Args: + args: Parsed command-line arguments. + + Returns: + Exit code (0 for success, non-zero for failure). + """ + action = args.config_action + + try: + if action == 'init': + self.config_manager.init_config() + self.output.print_success( + f"Configuration file created at {self.config_manager.config_path}" + ) + return 0 + + elif action == 'get': + value = self.config_manager.get(args.key) + if self.config.json_output: + self.output.output_json({args.key: value}) + else: + print(f"{args.key} = {value}") + return 0 + + elif action == 'set': + # Convert value to appropriate type + key = args.key + value = args.value + + # Try to convert to appropriate type based on current value + try: + current_value = self.config_manager.get(key) + if isinstance(current_value, bool): + value = value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(current_value, int): + value = int(value) + except (AttributeError, ValueError): + pass # Keep as string + + self.config_manager.set(key, value, logger=self.logger) + self.config_manager.save_config() + self.output.print_success(f"Set {key} = {value}") + return 0 + + elif action == 'list': + config_dict = self.config_manager.list_all() + if self.config.json_output: + self.output.output_json(config_dict) + else: + self.output.print_info("Current configuration:") + for key, value in config_dict.items(): + print(f" {key} = {value}") + return 0 + + except AttributeError as e: + error_msg = format_error_for_cli(e, context="Invalid configuration key") + self.output.print_error(error_msg) + self.output.print_info("Use 'config list' to see available configuration keys") + return 1 + except ValueError as e: + error_msg = format_error_for_cli(e, context="Invalid configuration value") + self.output.print_error(error_msg) + return 1 + except Exception as e: + self.logger.error(f"Configuration error: {e}", exc_info=True) + error_msg = format_error_for_cli(e, context="Configuration error") + self.output.print_error(error_msg) + return 1 + + def download_single(self, args: argparse.Namespace) -> int: + """Download a single mod with dependencies. + + Args: + args: Parsed command-line arguments containing: + - url: Mod URL to download + - output_path: Output directory (optional) + - include_optional: Include optional dependencies + - dry_run: Show what would be downloaded without downloading + - max_retries: Maximum retry attempts (optional) + + Returns: + Exit code (0 for success, non-zero for failure). + """ + # Pass the original input directly to Rust - let Rust handle ModID parsing + mod_url = args.url + target_version = args.target_mod_version + + # Update args with original values (Rust will handle ModID@version parsing) + args.url = mod_url + args.target_mod_version = target_version + + # Validate mod URL + try: + is_valid, error = validate_mod_url(mod_url) + if not is_valid: + self.output.print_error(error) + return 1 + except ValidationError as e: + error_msg = format_error_for_cli(e, context="Invalid mod URL") + self.output.print_error(error_msg) + return 1 + + # Update args with parsed values + args.url = mod_url + args.target_mod_version = target_version + + # Determine output path + output_path = args.output_path or self.config.default_output_path + using_default_path = args.output_path is None + + # Check if using default path and it doesn't exist + if using_default_path and not Path(output_path).exists(): + self.output.print_error( + "Factorio default mods directory not found. " + "Ensure you have Factorio installed and have run it at least once." + ) + self.output.print_info(f"Expected directory: {output_path}") + self.output.print_info( + "Alternatively, specify a custom output directory with -o/--output" + ) + return 1 + + # Validate and create output directory (only for custom paths) + try: + is_valid, error = validate_directory_path( + output_path, + must_exist=using_default_path, # Must exist if using default + create_if_missing=not using_default_path # Only create if custom path + ) + if not is_valid: + self.output.print_error(error) + return 1 + except Exception as e: + error_msg = format_error_for_cli(e, context="Cannot access output directory") + self.output.print_error(error_msg) + return 1 + + # Handle dry-run mode + if hasattr(args, 'dry_run') and args.dry_run: + return self._dry_run_download(args.url, output_path, args.include_optional) + + # Override max_retries if specified + if hasattr(args, 'max_retries') and args.max_retries is not None: + self.config.max_retries = args.max_retries + + # Log download start (only in verbose mode) + self.logger.debug(f"Starting download: {args.url}") + if not self.config.quiet: + self.output.print_info(f"Downloading mod from: {args.url}") + + # Create progress callback + current_mod = {'name': '', 'progress_bar': None, 'last_percentage': 0} + + def progress_callback(event_type, data): + if event_type == 'analyzing': + self.output.print_info(f"Analyzing dependencies for: {data}") + elif event_type == 'downloading': + mod_name, percentage, downloaded_mb, total_mb, speed = data + # Start new progress bar for new mod + if current_mod['name'] != mod_name: + if current_mod['progress_bar']: + current_mod['progress_bar'].close() + current_mod['name'] = mod_name + current_mod['last_percentage'] = 0 + # Don't create progress bar in quiet or JSON mode + if not self.config.quiet and not self.config.json_output: + current_mod['progress_bar'] = self.output.create_progress_bar( + total=100, + desc=f"Downloading {mod_name}" + ) + # Update progress + if current_mod['progress_bar']: + advance = int(percentage) - current_mod['last_percentage'] + if advance > 0: + current_mod['progress_bar'].update(advance) + current_mod['last_percentage'] = int(percentage) + elif event_type == 'complete': + if current_mod['progress_bar']: + current_mod['progress_bar'].close() + current_mod['progress_bar'] = None + self.output.print_success(f"Downloaded: {data}") + current_mod['name'] = '' + current_mod['last_percentage'] = 0 + elif event_type == 'error': + mod_name, error_msg = data + if current_mod['progress_bar']: + current_mod['progress_bar'].close() + current_mod['progress_bar'] = None + self.output.print_error(f"Failed to download {mod_name}: {error_msg}") + current_mod['name'] = '' + current_mod['last_percentage'] = 0 + + # Create downloader + try: + # Use Rust downloader if available + if self.use_rust: + # Get factorio version from args or config or default to 2.0 + factorio_version = getattr(args, 'factorio_version', None) or getattr(self.config, 'factorio_version', '2.0') + + # Get optional dependency settings + include_optional_all = getattr(args, 'include_optional_all', False) + target_mod_version = getattr(args, 'target_mod_version', None) + + self.logger.debug(f"Download parameters: factorio_version={factorio_version}, include_optional={args.include_optional}, include_optional_all={include_optional_all}, target_mod_version={target_mod_version}") + + # Use enhanced Rust downloader with beautiful progress bars + rust_result = self.rust_downloader.download_mod_enhanced( + mod_url=args.url, + output_path=output_path, + factorio_version=factorio_version, + include_optional=args.include_optional, + include_optional_all=include_optional_all, + target_mod_version=target_mod_version, + max_depth=10, + update_mod_list=using_default_path + ) + + # Convert Rust result to Python result format + class RustResultAdapter: + def __init__(self, rust_result): + self.success = rust_result.success + self.downloaded_mods = rust_result.downloaded_mods + self.failed_mods = rust_result.failed_mods + self.total_size = rust_result.total_size + self.duration = rust_result.duration + + result = RustResultAdapter(rust_result) + else: + # Fallback to Python downloader + downloader = CoreDownloader( + output_path=output_path, + include_optional=args.include_optional, + logger=self.logger, + config=self.config, + progress_callback=progress_callback + ) + + # Download mod + result = downloader.download_mod(args.url) + + # Close any remaining progress bar + if current_mod['progress_bar']: + current_mod['progress_bar'].close() + + # Display results + if result.success: + self.output.print_success( + f"Successfully downloaded {len(result.downloaded_mods)} mod(s)" + ) + + # Display summary + stats = { + 'total_mods': len(result.downloaded_mods) + len(result.failed_mods), + 'successful': len(result.downloaded_mods), + 'failed': len(result.failed_mods), + 'skipped': 0, + 'total_size': result.total_size, + 'duration': result.duration, + 'average_speed': result.total_size / result.duration / (1024 * 1024) if result.duration > 0 else 0 + } + self.output.print_summary(stats) + + # Update registry + if result.downloaded_mods: + self.registry.scan_directory(Path(output_path)) + self.registry.save_registry() + + return 0 + else: + self.output.print_error("Download failed") + if result.failed_mods: + self.output.print_error("Failed mods:") + for mod_name, error in result.failed_mods: + self.output.print_error(f" - {mod_name}: {error}") + return 1 + + except ModDownloaderError as e: + # Close any remaining progress bar + if current_mod['progress_bar']: + current_mod['progress_bar'].close() + self.logger.error(f"Error during download: {e}", exc_info=True) + error_msg = format_error_for_cli(e, context="Download failed") + self.output.print_error(error_msg) + return 1 + except Exception as e: + # Close any remaining progress bar + if current_mod['progress_bar']: + current_mod['progress_bar'].close() + self.logger.error(f"Unexpected error during download: {e}", exc_info=True) + error_msg = format_error_for_cli(e, context="Unexpected error during download") + self.output.print_error(error_msg) + return 1 + + def _dry_run_download(self, mod_url: str, output_path: str, include_optional: bool) -> int: + """Perform a dry-run download (show what would be downloaded). + + Args: + mod_url: Mod URL to analyze. + output_path: Output directory. + include_optional: Include optional dependencies. + + Returns: + Exit code (0 for success, non-zero for failure). + """ + self.output.print_info("Dry-run mode: analyzing dependencies...") + + try: + downloader = CoreDownloader( + output_path=output_path, + include_optional=include_optional, + logger=self.logger, + config=self.config + ) + + plan = downloader.get_download_plan(mod_url) + + # Display plan + self.output.print_info(f"\nWould download {plan.total_count} mod(s):") + for mod in plan.mods_to_download: + size_str = f" ({mod.size / 1024 / 1024:.2f} MB)" if mod.size else "" + optional_str = " [optional]" if mod.is_optional else "" + print(f" - {mod.name} v{mod.version}{size_str}{optional_str}") + + if plan.estimated_size > 0: + total_mb = plan.estimated_size / 1024 / 1024 + self.output.print_info(f"\nEstimated total size: {total_mb:.2f} MB") + + return 0 + + except Exception as e: + self.logger.error(f"Error during dry-run: {e}") + self.output.print_error(f"Error analyzing dependencies: {e}") + return 1 + + def _update_mod_list(self, mod_names: list[str], mods_directory: Path, enabled: bool = True) -> bool: + """Update Factorio's mod-list.json file with newly downloaded mods. + + Args: + mod_names: List of mod names to add. + mods_directory: Path to Factorio mods directory. + enabled: Whether mods should be enabled (default: True). + + Returns: + True if successful, False otherwise. + """ + import json + + mod_list_path = mods_directory / 'mod-list.json' + + try: + # Read existing mod-list.json or create new structure + if mod_list_path.exists(): + with open(mod_list_path, 'r', encoding='utf-8') as f: + mod_list = json.load(f) + else: + mod_list = {"mods": []} + + # Get existing mod names + existing_mods = {mod['name'] for mod in mod_list.get('mods', [])} + + # Add new mods that don't already exist + added_count = 0 + for mod_name in mod_names: + if mod_name not in existing_mods: + mod_list['mods'].append({ + "name": mod_name, + "enabled": enabled + }) + added_count += 1 + self.logger.info(f"Added {mod_name} to mod-list.json (enabled={enabled})") + + # Write updated mod-list.json + if added_count > 0: + with open(mod_list_path, 'w', encoding='utf-8') as f: + json.dump(mod_list, f, indent=2, ensure_ascii=False) + + self.output.print_success( + f"Updated mod-list.json: added {added_count} mod(s) (enabled={enabled})" + ) + return True + else: + self.output.print_info("All mods already in mod-list.json") + return True + + except Exception as e: + self.logger.error(f"Failed to update mod-list.json: {e}") + self.output.print_warning(f"Could not update mod-list.json: {e}") + return False + + def _batch_init(self) -> int: + """Create a template batch file in the Factorio mods directory. + + Returns: + Exit code (0 for success, non-zero for failure). + """ + import json + import os + + # Determine the Factorio mods directory + if os.name == 'nt': # Windows + appdata = os.getenv('APPDATA') + if appdata: + factorio_dir = Path(appdata) / 'Factorio' / 'mods' + else: + self.output.print_error("Could not determine APPDATA directory") + return 1 + else: # Linux/Mac + factorio_dir = Path.home() / '.factorio' / 'mods' + + # Create the batch file path + batch_file_path = factorio_dir / 'mods_dl.json' + + # Check if Factorio directory exists + if not factorio_dir.exists(): + self.output.print_warning( + f"Factorio mods directory not found: {factorio_dir}" + ) + self.output.print_info( + "Creating the directory... Make sure Factorio is installed." + ) + try: + factorio_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + self.output.print_error(f"Failed to create directory: {e}") + return 1 + + # Check if file already exists + if batch_file_path.exists(): + self.output.print_info(f"Batch file already exists: {batch_file_path}") + self.output.print_info("\nFile details:") + try: + import json + with open(batch_file_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + if isinstance(existing_data, dict): + self.output.print_info(f" Name: {existing_data.get('name', 'N/A')}") + self.output.print_info(f" Description: {existing_data.get('description', 'N/A')}") + mods_list = existing_data.get('mods', []) + self.output.print_info(f" Number of mods: {len(mods_list)}") + else: + self.output.print_info(f" Number of mods: {len(existing_data)}") + except Exception: + pass + + self.output.print_info("\nTo use this file:") + self.output.print_info(f" fmd batch mods_dl.json") + self.output.print_info("\nTo edit the file, open it in a text editor:") + self.output.print_info(f" {batch_file_path}") + return 0 + + # Create template batch file + template = { + "name": "My Factorio Modpack", + "description": "A collection of Factorio mods to download", + "version": "1.0", + "author": "Your Name", + "mods": [ + "https://mods.factorio.com/mod/example-mod-1", + "https://mods.factorio.com/mod/example-mod-2", + "https://mods.factorio.com/mod/example-mod-3" + ], + "_instructions": { + "1": "Replace the example URLs above with actual mod URLs from https://mods.factorio.com/", + "2": "Each URL should be in the format: https://mods.factorio.com/mod/", + "3": "You can add as many mods as you want to the 'mods' array", + "4": "Remove this '_instructions' section before using the file", + "5": "Run: fmd batch mods_dl.json" + } + } + + try: + with open(batch_file_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + + self.output.print_success(f"Template batch file created: {batch_file_path}") + self.output.print_info("\nNext steps:") + self.output.print_info(" 1. Edit the file and replace example URLs with actual mod URLs") + self.output.print_info(" 2. Remove the '_instructions' section") + self.output.print_info(f" 3. Run: fmd batch \"{batch_file_path}\"") + self.output.print_info("\nOr use a custom location:") + self.output.print_info(" fmd batch mods_dl.json -o ./custom-mods") + + # Log the action + self.logger.info(f"Created template batch file: {batch_file_path}") + + return 0 + + except Exception as e: + self.logger.error(f"Failed to create batch file: {e}") + self.output.print_error(f"Failed to create batch file: {e}") + return 1 + + def download_batch(self, args: argparse.Namespace) -> int: + """Download multiple mods from a batch file. + + Args: + args: Parsed command-line arguments containing: + - file: Path to batch file or "init" to create template + - output_path: Output directory (optional) + - include_optional: Include optional dependencies + - continue_on_error: Continue on errors + + Returns: + Exit code (0 for success, non-zero for failure). + """ + # Handle 'batch init' command + if args.file == 'init': + return self._batch_init() + + # Handle 'batch install' command - auto-find mods_dl.json + if args.file == 'install': + import os + if os.name == 'nt': # Windows + appdata = os.getenv('APPDATA') + if appdata: + factorio_batch = Path(appdata) / 'Factorio' / 'mods' / 'mods_dl.json' + if factorio_batch.exists(): + args.file = str(factorio_batch) + self.output.print_info(f"🚀 Auto-installing from: {factorio_batch}") + else: + self.output.print_error("❌ mods_dl.json not found in Factorio directory") + self.output.print_info("💡 Create it with: fmd batch init") + return 1 + else: + self.output.print_error("❌ Cannot find Factorio directory (APPDATA not set)") + return 1 + else: # Linux/Mac + factorio_batch = Path.home() / '.factorio' / 'mods' / 'mods_dl.json' + if factorio_batch.exists(): + args.file = str(factorio_batch) + self.output.print_info(f"🚀 Auto-installing from: {factorio_batch}") + else: + self.output.print_error("❌ mods_dl.json not found in Factorio directory") + self.output.print_info("💡 Create it with: fmd batch init") + return 1 + + # Check if file argument is provided + if not args.file: + self.output.print_error("Error: batch file path is required") + self.output.print_info("Usage: fmd batch ") + self.output.print_info(" or: fmd batch init (to create template)") + self.output.print_info(" or: fmd batch install (auto-find mods_dl.json)") + return 1 + + # Check if file is just a filename (not a path) - look in Factorio directory + batch_file_path = Path(args.file) + if not batch_file_path.exists(): + # Try to find it in Factorio mods directory + import os + if os.name == 'nt': # Windows + appdata = os.getenv('APPDATA') + if appdata: + factorio_batch = Path(appdata) / 'Factorio' / 'mods' / args.file + if factorio_batch.exists(): + batch_file_path = factorio_batch + self.output.print_info(f"Using batch file from Factorio directory: {batch_file_path}") + else: # Linux/Mac + factorio_batch = Path.home() / '.factorio' / 'mods' / args.file + if factorio_batch.exists(): + batch_file_path = factorio_batch + self.output.print_info(f"Using batch file from Factorio directory: {batch_file_path}") + + # Update args.file to use the resolved path + args.file = str(batch_file_path) + + # Validate batch file + is_valid, error = validate_batch_file(str(batch_file_path)) + if not is_valid: + self.output.print_error(error) + return 1 + + # Determine output path + output_path = args.output_path or self.config.default_output_path + using_default_path = args.output_path is None + + # Check if using default path and it doesn't exist + if using_default_path and not Path(output_path).exists(): + self.output.print_error( + "Factorio default mods directory not found. " + "Ensure you have Factorio installed and have run it at least once." + ) + self.output.print_info(f"Expected directory: {output_path}") + self.output.print_info( + "Alternatively, specify a custom output directory with -o/--output" + ) + return 1 + + # Validate and create output directory (only for custom paths) + is_valid, error = validate_directory_path( + output_path, + must_exist=using_default_path, # Must exist if using default + create_if_missing=not using_default_path # Only create if custom path + ) + if not is_valid: + self.output.print_error(error) + return 1 + + # Read batch file (JSON format) + try: + import json + with open(args.file, 'r', encoding='utf-8') as f: + batch_data = json.load(f) + except json.JSONDecodeError as e: + self.output.print_error(f"Invalid JSON format in batch file: {e}") + self.output.print_info("Batch file must be valid JSON. See documentation for format.") + return 1 + except Exception as e: + self.output.print_error(f"Error reading batch file: {e}") + return 1 + + # Parse URLs from JSON batch file + urls = [] + if isinstance(batch_data, dict): + # Support {"mods": [...]} format + if 'mods' in batch_data: + mods_list = batch_data['mods'] + if isinstance(mods_list, list): + for item in mods_list: + if isinstance(item, str): + urls.append(item) + elif isinstance(item, dict) and 'url' in item: + urls.append(item['url']) + else: + self.output.print_error("JSON batch file must contain a 'mods' array") + return 1 + elif isinstance(batch_data, list): + # Support direct array format + for item in batch_data: + if isinstance(item, str): + urls.append(item) + elif isinstance(item, dict) and 'url' in item: + urls.append(item['url']) + else: + self.output.print_error("Invalid batch file format. Must be JSON object or array.") + return 1 + + # Deduplicate URLs while preserving order + seen = set() + unique_urls = [] + for url in urls: + if url not in seen: + seen.add(url) + unique_urls.append(url) + + if not unique_urls: + self.output.print_error("No valid URLs found in batch file") + return 1 + + # Log batch download start (only in verbose mode) + self.logger.debug(f"Starting batch download of {len(unique_urls)} mods") + if not self.config.quiet: + self.output.print_info(f"Processing {len(unique_urls)} mod(s) from batch file") + + # Track statistics + total_mods = 0 + successful_downloads = 0 + failed_downloads = 0 + skipped_mods = 0 + total_size = 0 + start_time = time.time() + failed_list = [] + downloaded_mod_names = [] # Track mod names for mod-list.json update + + # Use Rust batch downloader if available + if self.use_rust: + try: + factorio_version = getattr(args, 'factorio_version', None) or getattr(self.config, 'factorio_version', '2.0') + include_optional_all = getattr(args, 'include_optional_all', False) + + # Use enhanced Rust batch downloader with beautiful progress bars + rust_result = self.rust_downloader.batch_download_enhanced( + mod_urls=unique_urls, + output_path=output_path, + factorio_version=factorio_version, + include_optional=args.include_optional, + include_optional_all=include_optional_all, + max_depth=10, + continue_on_error=args.continue_on_error + ) + + # Update statistics + total_mods = len(rust_result.downloaded_mods) + len(rust_result.failed_mods) + successful_downloads = len(rust_result.downloaded_mods) + failed_downloads = len(rust_result.failed_mods) + total_size = rust_result.total_size + duration = rust_result.duration + downloaded_mod_names = rust_result.downloaded_mods + failed_list = [(url, mod_name, error) for mod_name, error in rust_result.failed_mods] + + except Exception as e: + self.logger.error(f"Rust batch download failed: {e}") + self.output.print_warning("Falling back to Python downloader...") + self.use_rust = False + + # Fallback to Python downloader + if not self.use_rust: + # Download each mod + for i, url in enumerate(unique_urls, 1): + self.output.print_info(f"\n[{i}/{len(unique_urls)}] Processing: {url}") + + # Create a mock args object for download_single + class BatchArgs: + def __init__(self, url, output_path, include_optional): + self.url = url + self.output_path = output_path + self.include_optional = include_optional + self.dry_run = False + self.max_retries = None + + batch_args = BatchArgs(url, output_path, args.include_optional) + + try: + # Create downloader for this mod + downloader = CoreDownloader( + output_path=output_path, + include_optional=args.include_optional, + logger=self.logger, + config=self.config, + progress_callback=None # Simplified for batch mode + ) + + # Download the mod + result = downloader.download_mod(url) + + # Update statistics + total_mods += len(result.downloaded_mods) + len(result.failed_mods) + successful_downloads += len(result.downloaded_mods) + failed_downloads += len(result.failed_mods) + total_size += result.total_size + + # Track downloaded mod names for mod-list.json + if result.downloaded_mods: + downloaded_mod_names.extend(result.downloaded_mods) + + # Track failures + if result.failed_mods: + for mod_name, error in result.failed_mods: + failed_list.append((url, mod_name, error)) + + # Display result for this URL + if result.success: + self.output.print_success( + f"Downloaded {len(result.downloaded_mods)} mod(s) from {url}" + ) + else: + self.output.print_error(f"Failed to download from {url}") + if not args.continue_on_error: + self.output.print_error("Stopping batch download (use --continue-on-error to continue)") + break + + except Exception as e: + self.logger.error(f"Error downloading {url}: {e}") + self.output.print_error(f"Error downloading {url}: {e}") + failed_downloads += 1 + failed_list.append((url, url.split('/')[-1], str(e))) + + if not args.continue_on_error: + self.output.print_error("Stopping batch download (use --continue-on-error to continue)") + break + + # Calculate duration + duration = time.time() - start_time + + # Display summary (only if not quiet) + if not self.config.quiet: + self.output.print_info("\n" + "=" * 50) + self.output.print_info("Batch Download Summary") + self.output.print_info("=" * 50) + + stats = { + 'total_mods': total_mods, + 'successful': successful_downloads, + 'failed': failed_downloads, + 'skipped': skipped_mods, + 'total_size': total_size, + 'duration': duration, + 'average_speed': total_size / duration / (1024 * 1024) if duration > 0 else 0 + } + + self.output.print_summary(stats) + + # Display failed mods if any + if failed_list: + self.output.print_error(f"\nFailed downloads ({len(failed_list)}):") + for url, mod_name, error in failed_list: + # Try to provide helpful suggestion + suggestion = get_error_suggestion(Exception(error)) + if suggestion: + self.output.print_error(f" - {mod_name}: {error}") + self.output.print_info(f" 💡 {suggestion}") + else: + self.output.print_error(f" - {mod_name}: {error}") + + # Update registry + if successful_downloads > 0: + self.registry.scan_directory(Path(output_path)) + self.registry.save_registry() + + # Update mod-list.json if downloading to Factorio directory + if using_default_path and downloaded_mod_names: + # Determine if mods should be enabled or disabled + mods_enabled = not args.disabled if hasattr(args, 'disabled') else True + + self.output.print_info("\nUpdating mod-list.json...") + + # Use Rust function if available + if self.use_rust: + try: + self.rust_downloader.update_mod_list_json( + mod_names=downloaded_mod_names, + mods_directory=output_path, + enabled=mods_enabled + ) + self.output.print_success(f"Updated mod-list.json with {len(downloaded_mod_names)} mod(s)") + except Exception as e: + self.logger.warning(f"Rust mod-list update failed: {e}") + self._update_mod_list( + downloaded_mod_names, + Path(output_path), + enabled=mods_enabled + ) + else: + self._update_mod_list( + downloaded_mod_names, + Path(output_path), + enabled=mods_enabled + ) + + # Return exit code + if failed_downloads > 0: + return 1 + return 0 + + def check_updates(self, args: argparse.Namespace) -> int: + """Check for mod updates in a directory. + + Args: + args: Parsed command-line arguments containing: + - directory: Directory to scan for mods + + Returns: + Exit code (0 for success, non-zero for failure). + """ + # Validate directory + is_valid, error = validate_directory_path(args.directory, must_exist=True) + if not is_valid: + self.output.print_error(error) + return 1 + + directory = Path(args.directory) + + # Scan directory for mods + self.output.print_info(f"Scanning directory: {directory}") + self.logger.info(f"Scanning directory for mods: {directory}") + + try: + # Use registry to scan directory + found_mods = self.registry.scan_directory(directory) + + if not found_mods: + self.output.print_warning("No mod files found in directory") + return 0 + + self.output.print_info(f"Found {len(found_mods)} mod(s)") + + # Check for updates + self.output.print_info("Checking for updates...") + + mod_info_fetcher = ModInfoFetcher(self.logger, self.config) + updates_available = [] + + for mod_entry in found_mods: + try: + # Construct mod URL + mod_url = f"https://mods.factorio.com/mod/{mod_entry.name}" + + # Get latest version from mod portal + self.logger.debug(f"Checking {mod_entry.name} for updates") + latest_version = mod_info_fetcher.get_latest_version(mod_url) + + # Compare versions + if latest_version and latest_version != mod_entry.version: + updates_available.append({ + 'name': mod_entry.name, + 'current_version': mod_entry.version, + 'latest_version': latest_version, + 'url': mod_url + }) + self.logger.info( + f"Update available for {mod_entry.name}", + current=mod_entry.version, + latest=latest_version + ) + + except Exception as e: + self.logger.warning(f"Could not check updates for {mod_entry.name}: {e}") + self.output.print_warning(f"Could not check {mod_entry.name}: {e}") + + # Display results + if updates_available: + self.output.print_info(f"\nUpdates available for {len(updates_available)} mod(s):") + + if self.config.json_output: + self.output.output_json({'updates': updates_available}) + else: + for update in updates_available: + print(f" - {update['name']}: {update['current_version']} → {update['latest_version']}") + + self.output.print_info( + f"\nTo update, run: factorio-mod-downloader update {directory}" + ) + else: + self.output.print_success("All mods are up to date!") + + return 0 + + except Exception as e: + self.logger.error(f"Error checking for updates: {e}", exc_info=True) + error_msg = format_error_for_cli(e, context="Failed to check for updates") + self.output.print_error(error_msg) + return 1 + + def update_mods(self, args: argparse.Namespace) -> int: + """Update mods to latest versions. + + Args: + args: Parsed command-line arguments containing: + - directory: Directory containing mods + - mod_name: Specific mod to update (optional) + - replace: Replace old versions + + Returns: + Exit code (0 for success, non-zero for failure). + """ + # Validate directory + is_valid, error = validate_directory_path(args.directory, must_exist=True) + if not is_valid: + self.output.print_error(error) + return 1 + + directory = Path(args.directory) + + # Scan directory for mods + self.output.print_info(f"Scanning directory: {directory}") + self.logger.info(f"Scanning directory for mods: {directory}") + + try: + # Use registry to scan directory + found_mods = self.registry.scan_directory(directory) + + if not found_mods: + self.output.print_warning("No mod files found in directory") + return 0 + + # Filter by specific mod if requested + if hasattr(args, 'mod_name') and args.mod_name: + found_mods = [m for m in found_mods if m.name == args.mod_name] + if not found_mods: + self.output.print_error(f"Mod not found: {args.mod_name}") + return 1 + self.output.print_info(f"Updating specific mod: {args.mod_name}") + else: + self.output.print_info(f"Found {len(found_mods)} mod(s)") + + # Check for updates + self.output.print_info("Checking for updates...") + + mod_info_fetcher = ModInfoFetcher(self.logger, self.config) + updates_to_download = [] + + for mod_entry in found_mods: + try: + # Construct mod URL + mod_url = f"https://mods.factorio.com/mod/{mod_entry.name}" + + # Get latest version from mod portal + self.logger.debug(f"Checking {mod_entry.name} for updates") + latest_version = mod_info_fetcher.get_latest_version(mod_url) + + # Compare versions + if latest_version and latest_version != mod_entry.version: + updates_to_download.append({ + 'name': mod_entry.name, + 'current_version': mod_entry.version, + 'latest_version': latest_version, + 'url': mod_url, + 'old_file': mod_entry.file_path + }) + self.logger.info( + f"Update available for {mod_entry.name}", + current=mod_entry.version, + latest=latest_version + ) + + except Exception as e: + self.logger.warning(f"Could not check updates for {mod_entry.name}: {e}") + self.output.print_warning(f"Could not check {mod_entry.name}: {e}") + + # Check if any updates found + if not updates_to_download: + self.output.print_success("All mods are up to date!") + return 0 + + # Display updates to download + self.output.print_info(f"\nFound {len(updates_to_download)} update(s):") + for update in updates_to_download: + print(f" - {update['name']}: {update['current_version']} → {update['latest_version']}") + + # Download updates + self.output.print_info("\nDownloading updates...") + + successful_updates = 0 + failed_updates = [] + + for update in updates_to_download: + self.output.print_info(f"\nUpdating {update['name']}...") + + try: + # Create downloader + downloader = CoreDownloader( + output_path=str(directory), + include_optional=False, # Don't include optional deps for updates + logger=self.logger, + config=self.config, + progress_callback=None + ) + + # Download the update + result = downloader.download_mod(update['url']) + + if result.success and result.downloaded_mods: + successful_updates += 1 + self.output.print_success(f"Updated {update['name']} to {update['latest_version']}") + + # Replace old version if requested + if args.replace: + try: + old_file = Path(update['old_file']) + if old_file.exists(): + old_file.unlink() + self.output.print_info(f"Removed old version: {old_file.name}") + self.logger.info(f"Removed old version: {old_file}") + except Exception as e: + self.logger.warning(f"Could not remove old version: {e}") + self.output.print_warning(f"Could not remove old version: {e}") + else: + failed_updates.append((update['name'], "Download failed")) + self.output.print_error(f"Failed to update {update['name']}") + + except Exception as e: + self.logger.error(f"Error updating {update['name']}: {e}") + failed_updates.append((update['name'], str(e))) + self.output.print_error(f"Error updating {update['name']}: {e}") + + # Display summary + self.output.print_info("\n" + "=" * 50) + self.output.print_info("Update Summary") + self.output.print_info("=" * 50) + print(f" Successful: {successful_updates}") + print(f" Failed: {len(failed_updates)}") + + if failed_updates: + self.output.print_error("\nFailed updates:") + for mod_name, error in failed_updates: + self.output.print_error(f" - {mod_name}: {error}") + + # Update registry + if successful_updates > 0: + self.registry.scan_directory(directory) + self.registry.save_registry() + + # Return exit code + if failed_updates: + return 1 + return 0 + + except Exception as e: + self.logger.error(f"Error updating mods: {e}", exc_info=True) + error_msg = format_error_for_cli(e, context="Failed to update mods") + self.output.print_error(error_msg) + return 1 + + def validate(self, args: argparse.Namespace) -> int: + """Validate downloaded mod files. + + Args: + args: Parsed command-line arguments containing: + - directory: Directory to validate + + Returns: + Exit code (0 for success, non-zero for failure). + """ + # Validate directory + is_valid, error = validate_directory_path(args.directory, must_exist=True) + if not is_valid: + self.output.print_error(error) + return 1 + + directory = Path(args.directory) + + # Scan directory for mod files + self.output.print_info(f"Scanning directory: {directory}") + self.logger.info(f"Validating mod files in: {directory}") + + try: + # Find all .zip files in directory + mod_files = list(directory.glob('*.zip')) + + if not mod_files: + self.output.print_warning("No mod files found in directory") + return 0 + + self.output.print_info(f"Found {len(mod_files)} mod file(s)") + self.output.print_info("Validating ZIP file integrity...") + + # Validate each file + valid_files = [] + corrupted_files = [] + + for mod_file in mod_files: + try: + # Try to open and test the ZIP file + with zipfile.ZipFile(mod_file, 'r') as zf: + # Test the ZIP file integrity + bad_file = zf.testzip() + + if bad_file: + corrupted_files.append((mod_file.name, f"Corrupted file in archive: {bad_file}")) + self.logger.warning(f"Corrupted mod file: {mod_file.name}") + else: + valid_files.append(mod_file.name) + self.logger.debug(f"Valid mod file: {mod_file.name}") + + except zipfile.BadZipFile: + corrupted_files.append((mod_file.name, "Invalid ZIP file format")) + self.logger.warning(f"Invalid ZIP file: {mod_file.name}") + except Exception as e: + corrupted_files.append((mod_file.name, str(e))) + self.logger.warning(f"Error validating {mod_file.name}: {e}") + + # Display results + self.output.print_info("\n" + "=" * 50) + self.output.print_info("Validation Results") + self.output.print_info("=" * 50) + print(f" Total files: {len(mod_files)}") + print(f" Valid files: {len(valid_files)}") + print(f" Corrupted files: {len(corrupted_files)}") + self.output.print_info("=" * 50) + + # Display corrupted files + if corrupted_files: + self.output.print_error(f"\nCorrupted mod files ({len(corrupted_files)}):") + for file_name, error in corrupted_files: + self.output.print_error(f" - {file_name}: {error}") + + self.output.print_info("\nSuggestion: Re-download corrupted mods using:") + self.output.print_info(" factorio-mod-downloader download ") + + # Output JSON if requested + if self.config.json_output: + self.output.output_json({ + 'total': len(mod_files), + 'valid': len(valid_files), + 'corrupted': len(corrupted_files), + 'corrupted_files': [ + {'file': name, 'error': error} + for name, error in corrupted_files + ] + }) + + return 1 + else: + self.output.print_success("\nAll mod files are valid!") + + # Output JSON if requested + if self.config.json_output: + self.output.output_json({ + 'total': len(mod_files), + 'valid': len(valid_files), + 'corrupted': 0, + 'corrupted_files': [] + }) + + return 0 + + except Exception as e: + self.logger.error(f"Error validating mod files: {e}", exc_info=True) + error_msg = format_error_for_cli(e, context="Validation failed") + self.output.print_error(error_msg) + return 1 + + + +def cli_main(args: List[str]) -> int: + """CLI entry point. + + Initializes configuration and logging, parses arguments, + creates and runs the CLI application. + + Args: + args: Command-line arguments (excluding program name). + + Returns: + Exit code (0 for success, non-zero for failure). + """ + from factorio_mod_downloader.cli.parser import parse_args + + try: + # Parse command-line arguments + parsed_args = parse_args(args) + + # Initialize configuration manager + config_manager = ConfigManager() + config = config_manager.config + + # Override config with command-line arguments + if hasattr(parsed_args, 'log_level') and parsed_args.log_level: + config.log_level = parsed_args.log_level + + if hasattr(parsed_args, 'quiet') and parsed_args.quiet: + config.quiet = True + + if hasattr(parsed_args, 'verbose') and parsed_args.verbose: + config.verbose = True + config.log_level = 'DEBUG' + + if hasattr(parsed_args, 'json_output') and parsed_args.json_output: + config.json_output = True + + # Initialize logging system + console_level = None + if config.quiet: + console_level = 'ERROR' + elif config.verbose: + console_level = 'DEBUG' + + logger = LoggerSystem( + log_level=config.log_level, + console_level=console_level + ) + + # Log startup (only in verbose mode) + logger.debug("Factorio Mod Downloader CLI started") + logger.debug(f"Arguments: {args}") + + # Create and run CLI application + app = CLIApp(config, logger) + exit_code = app.run(parsed_args) + + # Log shutdown (only in verbose mode) + logger.debug(f"CLI exiting with code {exit_code}") + + return exit_code + + except KeyboardInterrupt: + print("\nOperation cancelled by user") + return 130 # Standard exit code for SIGINT + + except ValidationError as e: + # Handle validation errors with helpful messages + from factorio_mod_downloader.cli.error_formatter import format_error_for_cli + error_msg = format_error_for_cli(e, context="Invalid input") + print(error_msg, file=sys.stderr) + return 1 + + except Exception as e: + # Handle top-level exceptions + from factorio_mod_downloader.cli.error_formatter import format_error_for_cli + error_msg = format_error_for_cli(e, context="Fatal error") + print(error_msg, file=sys.stderr) + + # Try to log if logger is available + try: + if 'logger' in locals(): + logger.critical(f"Fatal error: {e}", exc_info=True) + except: + pass + + return 1 diff --git a/src/factorio_mod_downloader/cli/commands.py b/src/factorio_mod_downloader/cli/commands.py new file mode 100644 index 0000000..73b7ba5 --- /dev/null +++ b/src/factorio_mod_downloader/cli/commands.py @@ -0,0 +1,168 @@ +"""CLI command implementations for Factorio Mod Downloader.""" + +from pathlib import Path +from typing import Optional +from factorio_mod_downloader.infrastructure.config import ConfigManager + + +class ConfigCommands: + """Configuration management commands.""" + + def __init__(self, config_manager: ConfigManager, logger=None): + """Initialize config commands. + + Args: + config_manager: ConfigManager instance to use. + logger: Optional LoggerSystem instance for logging. + """ + self.config_manager = config_manager + self.logger = logger + + def init(self) -> int: + """Initialize default configuration file. + + Returns: + Exit code (0 for success). + """ + try: + self.config_manager.init_config() + print(f"Configuration file created at: {self.config_manager.config_path}") + print("\nDefault configuration:") + self._print_config_list() + + # Log configuration initialization + if self.logger: + self.logger.info( + "Configuration initialized", + path=str(self.config_manager.config_path) + ) + + return 0 + except Exception as e: + print(f"Error: Failed to initialize configuration: {e}") + if self.logger: + self.logger.error(f"Failed to initialize configuration: {e}") + return 1 + + def get(self, key: str) -> int: + """Get a configuration value. + + Args: + key: Configuration key to retrieve. + + Returns: + Exit code (0 for success, 1 for error). + """ + try: + value = self.config_manager.get(key) + print(f"{key}: {value}") + return 0 + except AttributeError as e: + print(f"Error: {e}") + print("\nAvailable configuration keys:") + for k in self.config_manager.list_all().keys(): + print(f" - {k}") + return 1 + except Exception as e: + print(f"Error: Failed to get configuration value: {e}") + return 1 + + def set(self, key: str, value: str) -> int: + """Set a configuration value. + + Args: + key: Configuration key to set. + value: Value to set (as string, will be converted to appropriate type). + + Returns: + Exit code (0 for success, 1 for error). + """ + try: + # Convert value to appropriate type based on current value type + current_value = self.config_manager.get(key) + converted_value = self._convert_value(value, type(current_value)) + + # Set the value (with logging) + self.config_manager.set(key, converted_value, logger=self.logger) + + # Save to file + self.config_manager.save_config() + + print(f"Configuration updated: {key} = {converted_value}") + print(f"Saved to: {self.config_manager.config_path}") + return 0 + except AttributeError as e: + print(f"Error: {e}") + print("\nAvailable configuration keys:") + for k in self.config_manager.list_all().keys(): + print(f" - {k}") + return 1 + except ValueError as e: + print(f"Error: Invalid value for {key}: {e}") + return 1 + except Exception as e: + print(f"Error: Failed to set configuration value: {e}") + return 1 + + def list(self) -> int: + """List all configuration values. + + Returns: + Exit code (0 for success). + """ + try: + self._print_config_list() + return 0 + except Exception as e: + print(f"Error: Failed to list configuration: {e}") + return 1 + + def _print_config_list(self): + """Print all configuration values in a formatted way.""" + config_dict = self.config_manager.list_all() + + print("Current configuration:") + print("-" * 50) + + max_key_length = max(len(k) for k in config_dict.keys()) + + for key, value in config_dict.items(): + print(f" {key:<{max_key_length}} : {value}") + + print("-" * 50) + print(f"Config file: {self.config_manager.config_path}") + + def _convert_value(self, value: str, target_type: type): + """Convert string value to target type. + + Args: + value: String value to convert. + target_type: Target type to convert to. + + Returns: + Converted value. + + Raises: + ValueError: If conversion fails. + """ + if target_type == bool: + # Handle boolean conversion + lower_value = value.lower() + if lower_value in ('true', 'yes', '1', 'on'): + return True + elif lower_value in ('false', 'no', '0', 'off'): + return False + else: + raise ValueError( + f"Cannot convert '{value}' to boolean. " + "Use: true/false, yes/no, 1/0, on/off" + ) + elif target_type == int: + try: + return int(value) + except ValueError: + raise ValueError(f"Cannot convert '{value}' to integer") + elif target_type == str: + return value + else: + raise ValueError(f"Unsupported type: {target_type}") diff --git a/src/factorio_mod_downloader/cli/error_formatter.py b/src/factorio_mod_downloader/cli/error_formatter.py new file mode 100644 index 0000000..e4bc8ba --- /dev/null +++ b/src/factorio_mod_downloader/cli/error_formatter.py @@ -0,0 +1,213 @@ +"""User-friendly error message formatting for CLI. + +This module provides utilities to format errors with actionable suggestions +and simplified messages for users while logging detailed errors. +""" + +from typing import Optional + +from factorio_mod_downloader.infrastructure.errors import ( + ModDownloaderError, + NetworkError, + ValidationError, + FileSystemError, + ParsingError, + ErrorCategory, + get_error_suggestion, + categorize_error +) + + +def format_error_message(error: Exception, include_suggestion: bool = True) -> str: + """Format an error with a user-friendly message. + + Args: + error: Exception to format + include_suggestion: Whether to include actionable suggestions + + Returns: + Formatted error message + """ + # Get base error message + if isinstance(error, ModDownloaderError): + message = error.message + else: + message = str(error) + + # Get suggestion if available + suggestion = None + if include_suggestion: + suggestion = get_error_suggestion(error) + + # Format the message + if suggestion: + return f"{message}\n💡 Suggestion: {suggestion}" + else: + return message + + +def get_common_error_suggestions() -> dict: + """Get a dictionary of common error patterns and their suggestions. + + Returns: + Dictionary mapping error patterns to suggestions + """ + return { + # Network errors + "connection": "Check your internet connection and try again", + "timeout": "The server is taking too long to respond. Try again later or increase the timeout.", + "dns": "Cannot resolve the server address. Check your DNS settings or internet connection.", + "ssl": "SSL certificate verification failed. Check your system time and date settings.", + "refused": "Connection refused by server. The server may be down or blocking requests.", + + # Filesystem errors + "permission": "Check file permissions and ensure you have write access to the output directory", + "disk": "Free up disk space and try again", + "not found": "The specified path does not exist. Check the path and try again.", + "exists": "A file or directory with that name already exists", + + # Validation errors + "invalid url": "Check the mod URL format. Expected: https://mods.factorio.com/mod/", + "invalid format": "The input format is incorrect. Check the documentation for the correct format.", + "empty": "The input cannot be empty. Please provide a valid value.", + + # Parsing errors + "parse": "Failed to extract information from the page. The page format may have changed.", + "not found in page": "Could not find expected information on the page. The mod may not exist or the page format changed.", + "no version": "The mod does not have any published versions available for download." + } + + +def suggest_fix_for_error(error: Exception) -> Optional[str]: + """Suggest a fix for a given error based on common patterns. + + Args: + error: Exception to analyze + + Returns: + Suggestion string if a match is found, None otherwise + """ + error_str = str(error).lower() + suggestions = get_common_error_suggestions() + + # Check for pattern matches + for pattern, suggestion in suggestions.items(): + if pattern in error_str: + return suggestion + + # Category-based suggestions + category = categorize_error(error) + + if category == ErrorCategory.NETWORK: + return "Check your internet connection and try again. If the problem persists, the server may be temporarily unavailable." + elif category == ErrorCategory.FILESYSTEM: + return "Check file permissions, available disk space, and ensure the path is valid." + elif category == ErrorCategory.VALIDATION: + return "Verify your input is in the correct format and try again." + elif category == ErrorCategory.PARSING: + return "The mod page may be temporarily unavailable or the format has changed. Try again later." + + return None + + +def format_error_for_cli( + error: Exception, + context: Optional[str] = None, + show_details: bool = False +) -> str: + """Format an error message for CLI display. + + Args: + error: Exception to format + context: Optional context about what was being done when error occurred + show_details: Whether to show detailed error information + + Returns: + Formatted error message for CLI display + """ + lines = [] + + # Add context if provided + if context: + lines.append(f"Error: {context}") + + # Add main error message + if isinstance(error, ModDownloaderError): + lines.append(f" {error.message}") + else: + lines.append(f" {str(error)}") + + # Add suggestion + suggestion = get_error_suggestion(error) + if not suggestion: + suggestion = suggest_fix_for_error(error) + + if suggestion: + lines.append(f"\n💡 Suggestion: {suggestion}") + + # Add details if requested + if show_details: + lines.append(f"\nError type: {type(error).__name__}") + if isinstance(error, ModDownloaderError): + lines.append(f"Category: {error.category.value}") + lines.append(f"Retryable: {error.retryable}") + + return "\n".join(lines) + + +def format_multiple_errors( + errors: list, + max_display: int = 5 +) -> str: + """Format multiple errors for display. + + Args: + errors: List of (context, error) tuples + max_display: Maximum number of errors to display in detail + + Returns: + Formatted error summary + """ + if not errors: + return "No errors to display" + + lines = [f"Encountered {len(errors)} error(s):"] + + # Display first few errors in detail + for i, (context, error) in enumerate(errors[:max_display], 1): + lines.append(f"\n{i}. {context}") + lines.append(f" {str(error)}") + + suggestion = get_error_suggestion(error) + if suggestion: + lines.append(f" 💡 {suggestion}") + + # If there are more errors, show count + if len(errors) > max_display: + remaining = len(errors) - max_display + lines.append(f"\n... and {remaining} more error(s)") + + return "\n".join(lines) + + +def get_retry_message(attempt: int, max_attempts: int, error: Exception) -> str: + """Get a message for retry attempts. + + Args: + attempt: Current attempt number + max_attempts: Maximum number of attempts + error: Error that occurred + + Returns: + Formatted retry message + """ + remaining = max_attempts - attempt + + if remaining > 0: + return f"Retrying... ({remaining} attempt(s) remaining)" + else: + suggestion = get_error_suggestion(error) + if suggestion: + return f"Max retries exceeded. {suggestion}" + else: + return "Max retries exceeded. Please try again later." diff --git a/src/factorio_mod_downloader/cli/output.py b/src/factorio_mod_downloader/cli/output.py new file mode 100644 index 0000000..cb99370 --- /dev/null +++ b/src/factorio_mod_downloader/cli/output.py @@ -0,0 +1,276 @@ +"""CLI output formatting and progress display.""" + +import json +import sys +from typing import Any, Dict, Optional +from colorama import Fore, Style, init as colorama_init +from rich.progress import Progress, BarColumn, TextColumn, TimeRemainingColumn, DownloadColumn, TransferSpeedColumn +from rich.console import Console + +from factorio_mod_downloader.infrastructure.config import Config + + +# Initialize colorama for cross-platform colored output +colorama_init(autoreset=True) + + +class OutputFormatter: + """Handles CLI output formatting and progress display.""" + + def __init__(self, config: Config): + """Initialize OutputFormatter. + + Args: + config: Configuration object with output settings. + """ + self.quiet = config.quiet + self.verbose = config.verbose + self.json_output = config.json_output + self.use_colors = self._detect_color_support() + self.console = Console() + + def _detect_color_support(self) -> bool: + """Detect if terminal supports colored output. + + Returns: + True if colors are supported, False otherwise. + """ + # Disable colors in JSON mode or quiet mode + if self.json_output or self.quiet: + return False + + # Check if stdout is a terminal + if not sys.stdout.isatty(): + return False + + # Check for common environment variables that indicate color support + import os + term = os.environ.get('TERM', '') + colorterm = os.environ.get('COLORTERM', '') + + # Most modern terminals support colors + if 'color' in term.lower() or colorterm: + return True + + # Windows terminal and common Unix terminals + if term in ('xterm', 'xterm-256color', 'screen', 'screen-256color', + 'tmux', 'tmux-256color', 'rxvt-unicode', 'rxvt-unicode-256color'): + return True + + # Default to True on Windows (colorama handles it) + if sys.platform == 'win32': + return True + + return False + + def print_info(self, message: str): + """Print informational message. + + Args: + message: Message to print. + """ + if self.quiet: + return + + if self.json_output: + self._print_json({'level': 'info', 'message': message}) + elif self.use_colors: + print(f"{Fore.BLUE}ℹ {message}{Style.RESET_ALL}") + else: + print(f"[INFO] {message}") + + def print_success(self, message: str): + """Print success message in green. + + Args: + message: Message to print. + """ + if self.quiet: + return + + if self.json_output: + self._print_json({'level': 'success', 'message': message}) + elif self.use_colors: + print(f"{Fore.GREEN}✓ {message}{Style.RESET_ALL}") + else: + print(f"[SUCCESS] {message}") + + def print_error(self, message: str): + """Print error message in red. + + Args: + message: Message to print. + """ + # Always print errors, even in quiet mode + if self.json_output: + self._print_json({'level': 'error', 'message': message}) + elif self.use_colors: + print(f"{Fore.RED}✗ {message}{Style.RESET_ALL}", file=sys.stderr) + else: + print(f"[ERROR] {message}", file=sys.stderr) + + def print_warning(self, message: str): + """Print warning message in yellow. + + Args: + message: Message to print. + """ + if self.quiet: + return + + if self.json_output: + self._print_json({'level': 'warning', 'message': message}) + elif self.use_colors: + print(f"{Fore.YELLOW}⚠ {message}{Style.RESET_ALL}") + else: + print(f"[WARNING] {message}") + + def _print_json(self, data: Dict[str, Any]): + """Print data in JSON format. + + Args: + data: Dictionary to print as JSON. + """ + print(json.dumps(data)) + + def create_progress_bar(self, total: int, desc: str) -> 'ProgressBar': + """Create a progress bar for downloads. + + Args: + total: Total number of units. + desc: Description of the progress bar. + + Returns: + ProgressBar instance. + """ + # Disable progress bar in quiet mode, JSON mode, or non-interactive terminals + disable = self.quiet or self.json_output or not sys.stdout.isatty() + return ProgressBar(total, desc, disable=disable) + + def print_summary(self, stats: Dict[str, Any]): + """Print download summary. + + Args: + stats: Dictionary containing download statistics with keys: + - total_mods: Total number of mods processed + - successful: Number of successful downloads + - failed: Number of failed downloads + - skipped: Number of skipped mods + - total_size: Total size in bytes + - duration: Duration in seconds + - average_speed: Average speed in MB/s + """ + if self.json_output: + self.output_json(stats) + return + + if self.quiet: + return + + # Format size + size_mb = stats.get('total_size', 0) / (1024 * 1024) + duration = stats.get('duration', 0) + avg_speed = stats.get('average_speed', 0) + + # Print summary + print("\n" + "=" * 50) + print("Download Summary") + print("=" * 50) + print(f"Total mods: {stats.get('total_mods', 0)}") + print(f"Successful: {stats.get('successful', 0)}") + print(f"Failed: {stats.get('failed', 0)}") + print(f"Skipped: {stats.get('skipped', 0)}") + print(f"Total size: {size_mb:.2f} MB") + print(f"Duration: {duration:.2f} seconds") + print(f"Average speed: {avg_speed:.2f} MB/s") + print("=" * 50) + + def output_json(self, data: Dict[str, Any]): + """Output data in JSON format for machine parsing. + + Args: + data: Dictionary to output as JSON. + """ + print(json.dumps(data, indent=2)) + + +class ProgressBar: + """CLI progress bar wrapper using rich.""" + + def __init__(self, total: int, desc: str, disable: bool = False): + """Initialize progress bar. + + Args: + total: Total number of units. + desc: Description of the progress bar. + disable: Whether to disable the progress bar. + """ + self.total = total + self.desc = desc + self.disable = disable + self.progress = None + self.task_id = None + + if not self.disable: + self.progress = Progress( + TextColumn("[bold blue]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + DownloadColumn(), + TransferSpeedColumn(), + TimeRemainingColumn(), + ) + self.progress.start() + self.task_id = self.progress.add_task(self.desc, total=self.total) + + def update(self, n: int): + """Update progress by n units. + + Args: + n: Number of units to advance. + """ + if not self.disable and self.progress and self.task_id is not None: + self.progress.update(self.task_id, advance=n) + + def set_postfix(self, **kwargs): + """Set progress bar postfix (speed, ETA, etc.). + + Args: + **kwargs: Key-value pairs to display. + """ + if not self.disable and self.progress and self.task_id is not None: + # Update task description with postfix + postfix_str = ", ".join(f"{k}={v}" for k, v in kwargs.items()) + if postfix_str: + self.progress.update(self.task_id, description=f"{self.desc} ({postfix_str})") + + def close(self): + """Close and finalize progress bar.""" + if not self.disable and self.progress: + self.progress.stop() + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + +def create_progress_bar(total: int, desc: str, quiet: bool = False, + json_output: bool = False) -> ProgressBar: + """Create a progress bar for downloads. + + Args: + total: Total number of units. + desc: Description of the progress bar. + quiet: Whether quiet mode is enabled. + json_output: Whether JSON output mode is enabled. + + Returns: + ProgressBar instance. + """ + # Disable progress bar in quiet mode, JSON mode, or non-interactive terminals + disable = quiet or json_output or not sys.stdout.isatty() + return ProgressBar(total, desc, disable=disable) diff --git a/src/factorio_mod_downloader/cli/parser.py b/src/factorio_mod_downloader/cli/parser.py new file mode 100644 index 0000000..c59085f --- /dev/null +++ b/src/factorio_mod_downloader/cli/parser.py @@ -0,0 +1,740 @@ +"""CLI argument parsing for Factorio Mod Downloader.""" + +import argparse +import sys +from typing import List, Optional +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.markdown import Markdown + + +# Version information +__version__ = "0.3.0" + + +def print_simple_help(): + """Print simple beginner-friendly help.""" + console = Console() + + console.print("\n[bold cyan]Factorio Mod Downloader v2.0.0[/bold cyan]") + console.print("[dim]Download Factorio mods with automatic dependency resolution[/dim]\n") + + # Quick Start + console.print(Panel.fit( + "[bold]Quick Start Guide[/bold]\n\n" + "1. Download a single mod:\n" + " [cyan]fmd download https://mods.factorio.com/mod/Krastorio2[/cyan]\n\n" + "2. Create batch file template:\n" + " [cyan]fmd batch init[/cyan]\n\n" + "3. Download multiple mods from JSON file:\n" + " [cyan]fmd batch mods.json[/cyan]\n\n" + "4. Check for mod updates:\n" + " [cyan]fmd check-updates ./mods[/cyan]\n\n" + "5. Initialize configuration:\n" + " [cyan]fmd config init[/cyan]", + title="🚀 Getting Started", + border_style="green" + )) + + # Available Commands + console.print("\n[bold yellow]Available Commands:[/bold yellow]") + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Command", style="cyan") + table.add_column("Description") + + table.add_row("download", "Download a single mod with dependencies") + table.add_row("batch", "Download multiple mods from JSON file") + table.add_row("check-updates", "Check for mod updates") + table.add_row("update", "Update mods to latest versions") + table.add_row("config", "Manage configuration") + table.add_row("validate", "Validate downloaded mod files") + + console.print(table) + + # More Help + console.print("\n[bold]Need more help?[/bold]") + console.print(" • Detailed help: [cyan]fmd -hh[/cyan]") + console.print(" • Command help: [cyan]fmd --help[/cyan]") + console.print(" • Version info: [cyan]fmd --version[/cyan]\n") + + +def print_detailed_help(): + """Print comprehensive detailed help with rich formatting.""" + console = Console() + + console.print("\n[bold cyan]╔═══════════════════════════════════════════════════════════════╗[/bold cyan]") + console.print("[bold cyan]║ Factorio Mod Downloader v2.0.0 - Complete Guide ║[/bold cyan]") + console.print("[bold cyan]╚═══════════════════════════════════════════════════════════════╝[/bold cyan]\n") + + # Overview + console.print(Panel( + "[bold]Factorio Mod Downloader[/bold] is a powerful CLI tool that downloads Factorio mods\n" + "from the official mod portal with automatic dependency resolution.\n\n" + "[bold green]✓[/bold green] Automatic dependency resolution\n" + "[bold green]✓[/bold green] Batch downloads from JSON files\n" + "[bold green]✓[/bold green] Resume interrupted downloads\n" + "[bold green]✓[/bold green] Update checking and management\n" + "[bold green]✓[/bold green] Configuration file support\n" + "[bold green]✓[/bold green] Rich terminal output with progress bars", + title="📦 Overview", + border_style="blue" + )) + + # Commands Section + console.print("\n[bold yellow]═══ COMMANDS ═══[/bold yellow]\n") + + # Download Command + console.print(Panel.fit( + "[bold cyan]fmd download [OPTIONS][/bold cyan]\n\n" + "[bold]Description:[/bold]\n" + "Download a single mod and all its required dependencies using blazing-fast Rust engine.\n\n" + "[bold]🔥 New Features:[/bold]\n" + " • [green]Rust-powered parallel downloads[/green] (2-4x faster)\n" + " • [green]Beautiful progress bars[/green] with real-time stats\n" + " • [green]Smart dependency resolution[/green]\n" + " • [green]Mod ID support[/green] - no need for full URLs!\n\n" + "[bold]Options:[/bold]\n" + " -o, --output PATH Output directory (default: Factorio mods folder)\n" + " --include-optional Include main mod's optional dependencies\n" + " --include-optional-all Include ALL optional deps recursively 🤓\n" + " --target-mod-version VER Specific version (e.g., 2.0.10)\n" + " @ VER Version alias: MOD_ID@2.0.10 🚀\n" + " --factorio-version VER Target Factorio version (default: 2.0)\n" + " -fv VER Factorio version alias 🤓\n" + " --dry-run Preview download plan without downloading\n" + " --max-retries N Maximum retry attempts (default: 3)\n" + " --no-resume Disable resume functionality\n\n" + "[bold]🤓 Nerd Examples:[/bold]\n" + " [dim]# Download latest version[/dim]\n" + " fmd download Krastorio2\n\n" + " [dim]# Download specific version with alias[/dim]\n" + " fmd download space-exploration@0.6.138\n\n" + " [dim]# Download for older Factorio with all optional deps[/dim]\n" + " fmd download FNEI -fv 1.1 --include-optional-all\n\n" + " [dim]# Dry run to see what would be downloaded[/dim]\n" + " fmd download \"Side%20Inserters@2.5.0\" --dry-run\n\n" + " [dim]# Download with optional dependencies to custom folder[/dim]\n" + " fmd download https://mods.factorio.com/mod/space-exploration \\\n" + " --include-optional -o D:\\MyMods\n\n" + " [dim]# Preview download without actually downloading[/dim]\n" + " fmd download https://mods.factorio.com/mod/FNEI --dry-run", + title="📥 download", + border_style="cyan" + )) + + # Batch Command + console.print("\n") + console.print(Panel.fit( + "[bold cyan]fmd batch [OPTIONS][/bold cyan]\n\n" + "[bold]Description:[/bold]\n" + "Download multiple mods from a JSON batch file with Rust-powered speed.\n\n" + "[bold]🔥 New Commands:[/bold]\n" + " • [green]install[/green] - Auto-find and download from mods_dl.json 🚀\n" + " • [green]Rust batch engine[/green] - Parallel downloads for maximum speed\n\n" + "[bold]JSON Format:[/bold]\n" + '{\n' + ' "name": "My Modpack",\n' + ' "mods": [\n' + ' "Krastorio2", [dim]// Mod ID (recommended)[/dim]\n' + ' "space-exploration@0.6.138", [dim]// With version[/dim]\n' + ' "https://mods.factorio.com/mod/FNEI" [dim]// Full URL[/dim]\n' + ' ]\n' + '}\n\n' + "[bold]Options:[/bold]\n" + " -o, --output PATH Output directory\n" + " --include-optional Include optional dependencies\n" + " --include-optional-all Include ALL optional deps recursively 🤓\n" + " --continue-on-error Continue if one mod fails\n" + " --enabled Add mods to mod-list.json as enabled (default)\n" + " --disabled Add mods to mod-list.json as disabled\n\n" + "[bold]🤓 Nerd Examples:[/bold]\n" + " [dim]# Auto-install from Factorio directory[/dim]\n" + " fmd batch install\n\n" + " [dim]# Create template batch file[/dim]\n" + " fmd batch init\n\n" + " [dim]# Download with all optional dependencies[/dim]\n" + " fmd batch mods.json --include-optional-all\n\n" + " [dim]# Download to custom directory, continue on errors[/dim]\n" + " fmd batch mods.json -o ./mods --continue-on-error", + title="📋 batch", + border_style="cyan" + )) + + # Check Updates Command + console.print("\n") + console.print(Panel.fit( + "[bold cyan]fmd check-updates [/bold cyan]\n\n" + "[bold]Description:[/bold]\n" + "Scan a directory for installed mods and check if newer versions are available.\n\n" + "[bold]Examples:[/bold]\n" + " [dim]# Check for updates in Factorio mods directory[/dim]\n" + " fmd check-updates %APPDATA%\\Factorio\\mods\n\n" + " [dim]# Check custom directory[/dim]\n" + " fmd check-updates ./my-mods", + title="🔍 check-updates", + border_style="cyan" + )) + + # Update Command + console.print("\n") + console.print(Panel.fit( + "[bold cyan]fmd update [OPTIONS][/bold cyan]\n\n" + "[bold]Description:[/bold]\n" + "Download newer versions of installed mods.\n\n" + "[bold]Options:[/bold]\n" + " --update-mod NAME Update only specific mod\n" + " --replace Replace old versions (default: keep both)\n\n" + "[bold]Examples:[/bold]\n" + " [dim]# Update all mods[/dim]\n" + " fmd update ./mods\n\n" + " [dim]# Update specific mod and replace old version[/dim]\n" + " fmd update ./mods --update-mod Krastorio2 --replace", + title="⬆️ update", + border_style="cyan" + )) + + # Config Command + console.print("\n") + console.print(Panel.fit( + "[bold cyan]fmd config [ARGS][/bold cyan]\n\n" + "[bold]Actions:[/bold]\n" + " init Create default configuration file\n" + " list Show all configuration values\n" + " get Get specific configuration value\n" + " set Set configuration value\n\n" + "[bold]Configuration Keys:[/bold]\n" + " default_output_path Default download directory\n" + " include_optional_deps Include optional dependencies (true/false)\n" + " max_retries Maximum retry attempts (number)\n" + " retry_delay Delay between retries in seconds\n" + " log_level Logging level (DEBUG/INFO/WARNING/ERROR)\n" + " concurrent_downloads Number of concurrent downloads\n\n" + "[bold]Examples:[/bold]\n" + " [dim]# Initialize configuration[/dim]\n" + " fmd config init\n\n" + " [dim]# Set default output path[/dim]\n" + " fmd config set default_output_path D:\\MyMods\n\n" + " [dim]# View all settings[/dim]\n" + " fmd config list", + title="⚙️ config", + border_style="cyan" + )) + + # Validate Command + console.print("\n") + console.print(Panel.fit( + "[bold cyan]fmd validate [/bold cyan]\n\n" + "[bold]Description:[/bold]\n" + "Check the integrity of downloaded mod files in a directory.\n\n" + "[bold]Examples:[/bold]\n" + " fmd validate ./mods", + title="✓ validate", + border_style="cyan" + )) + + # Global Options + console.print("\n[bold yellow]═══ GLOBAL OPTIONS ═══[/bold yellow]\n") + console.print(Panel.fit( + "[bold]--version[/bold] Show version number\n" + "[bold]--log-level LEVEL[/bold] Set logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL)\n" + "[bold]-q, --quiet[/bold] Suppress all output except errors\n" + "[bold]-v, --verbose[/bold] Enable verbose output (DEBUG level)\n" + "[bold]--json[/bold] Output in JSON format for machine parsing\n" + "[bold]-h, --help[/bold] Show simple help message\n" + "[bold]-hh[/bold] Show this detailed help (you are here!)", + border_style="yellow" + )) + + # Important Notes + console.print("\n[bold yellow]═══ IMPORTANT NOTES ═══[/bold yellow]\n") + console.print(Panel( + "[bold red]⚠[/bold red] [bold]Default Directory:[/bold]\n" + " By default, mods are downloaded to your Factorio installation directory:\n" + " • Windows: [cyan]%APPDATA%\\Factorio\\mods[/cyan]\n" + " • Linux: [cyan]~/.factorio/mods[/cyan]\n\n" + " If Factorio is not installed or hasn't been run at least once, you MUST\n" + " specify a custom output directory with [cyan]-o/--output[/cyan].\n\n" + "[bold green]✓[/bold green] [bold]Batch Files:[/bold]\n" + " Batch files must be in JSON format with a [cyan].json[/cyan] extension.\n" + " Use [cyan]fmd batch init[/cyan] to create a template in Factorio directory.\n" + " Just filename works: [cyan]fmd batch mods_dl.json[/cyan] auto-finds it.\n\n" + "[bold green]✓[/bold green] [bold]Automatic mod-list.json Update:[/bold]\n" + " When downloading to Factorio directory, mods are automatically added\n" + " to mod-list.json. Use [cyan]--disabled[/cyan] to add them as disabled.\n\n" + "[bold blue]ℹ[/bold blue] [bold]Configuration:[/bold]\n" + " Configuration is stored at: [cyan]~/.fmd/config.yaml[/cyan]\n" + " Logs are stored at: [cyan]~/.fmd/logs/[/cyan]", + border_style="yellow" + )) + + # Footer + console.print("\n[bold cyan]═══════════════════════════════════════════════════════════════[/bold cyan]") + console.print("[dim]For more information, visit: https://github.com/vaibhavvikas/fmd[/dim]") + console.print("[bold cyan]═══════════════════════════════════════════════════════════════[/bold cyan]\n") + + +def check_for_detailed_help(args: List[str]) -> bool: + """Check if -hh flag is present and show detailed help. + + Args: + args: Command-line arguments. + + Returns: + True if detailed help was shown, False otherwise. + """ + if '-hh' in args: + print_detailed_help() + return True + return False + + +def create_parser() -> argparse.ArgumentParser: + """Create the main argument parser with all subcommands. + + Returns: + Configured ArgumentParser instance. + """ + # Main parser with custom help + parser = argparse.ArgumentParser( + prog='fmd', + description='Download Factorio mods from the mod portal with dependency resolution.', + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False, # We'll handle help ourselves + epilog=""" +This is the normal help command. More detailed help is available with -hh + +Quick Examples: + fmd download https://mods.factorio.com/mod/Krastorio2 + fmd batch mods.json + fmd check-updates ./mods + fmd config init + +For detailed help with all commands and options: + fmd -hh + """ + ) + + # Add custom help argument + parser.add_argument( + '-h', '--help', + action='store_true', + help='Show this help message' + ) + + # Global options + parser.add_argument( + '--version', + action='version', + version=f'%(prog)s {__version__}' + ) + + parser.add_argument( + '--log-level', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + default=None, + help='Set logging level (default: INFO)' + ) + + parser.add_argument( + '--quiet', '-q', + action='store_true', + help='Suppress all output except errors' + ) + + parser.add_argument( + '--verbose', '-v', + action='store_true', + help='Enable verbose output (DEBUG level)' + ) + + parser.add_argument( + '--json', + action='store_true', + dest='json_output', + help='Output in JSON format for machine parsing' + ) + + # Create subparsers + subparsers = parser.add_subparsers( + dest='command', + help='Available commands', + required=False + ) + + # Download subcommand + _add_download_subcommand(subparsers) + + # Batch subcommand + _add_batch_subcommand(subparsers) + + # Check-updates subcommand + _add_check_updates_subcommand(subparsers) + + # Update subcommand + _add_update_subcommand(subparsers) + + # Config subcommand + _add_config_subcommand(subparsers) + + # Validate subcommand + _add_validate_subcommand(subparsers) + + return parser + + +def _add_download_subcommand(subparsers): + """Add download subcommand to parser. + + Args: + subparsers: Subparsers object to add command to. + """ + download_parser = subparsers.add_parser( + 'download', + help='Download a single mod with dependencies', + description="""Download a Factorio mod and its dependencies from the mod portal. + +This command will: + 1. Analyze the mod and resolve all required dependencies + 2. Download the mod and all dependencies recursively + 3. Display progress with download speed and ETA + 4. Resume interrupted downloads automatically (unless --no-resume) + 5. Retry failed downloads up to max-retries times + +The mod URL should be in the format: https://mods.factorio.com/mod/ + """, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + download_parser.add_argument( + 'url', + help='Mod URL or ModID to download (e.g., https://mods.factorio.com/mod/Krastorio2, Krastorio2, Krastorio2@1.3.25, Krastorio2@latest)' + ) + + download_parser.add_argument( + '--output', '-o', + dest='output_path', + default=None, + help='Output directory for downloaded mods (default: from config or ./mods)' + ) + + download_parser.add_argument( + '--include-optional', + action='store_true', + help='Include main mod optional dependencies and their required dependencies' + ) + + download_parser.add_argument( + '--include-optional-all', + action='store_true', + dest='include_optional_all', + help='Include all optional dependencies recursively (nested optional deps)' + ) + + download_parser.add_argument( + '--target-mod-version', + dest='target_mod_version', + default=None, + help='Specific version of the main mod to download (e.g., 2.0.10). Use @VERSION syntax in URL: MOD_ID@VERSION' + ) + + download_parser.add_argument( + '--factorio-version', '-fv', + dest='factorio_version', + default='2.0', + help='Target Factorio version for compatibility (default: 2.0)' + ) + + download_parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be downloaded without actually downloading' + ) + + download_parser.add_argument( + '--resume', + action='store_true', + default=True, + help='Resume interrupted downloads (default: enabled)' + ) + + download_parser.add_argument( + '--no-resume', + action='store_false', + dest='resume', + help='Disable resume functionality' + ) + + download_parser.add_argument( + '--max-retries', + type=int, + default=None, + help='Maximum number of retry attempts for failed downloads (default: from config or 3)' + ) + + +def _add_batch_subcommand(subparsers): + """Add batch subcommand to parser. + + Args: + subparsers: Subparsers object to add command to. + """ + batch_parser = subparsers.add_parser( + 'batch', + help='Download multiple mods from a JSON file', + description="""Download multiple Factorio mods from a JSON batch file. + +Batch file format (JSON): + - Must be a valid JSON file with .json extension + - Contains a 'mods' array with mod URLs + - Duplicate mods are automatically deduplicated + +Example batch file (mods.json): + { + "name": "My Factorio Modpack", + "description": "Collection of essential mods", + "mods": [ + "https://mods.factorio.com/mod/Krastorio2", + "https://mods.factorio.com/mod/space-exploration", + "https://mods.factorio.com/mod/FNEI", + "https://mods.factorio.com/mod/even-distribution" + ] + } + +Alternative format (direct array): + [ + "https://mods.factorio.com/mod/Krastorio2", + "https://mods.factorio.com/mod/space-exploration" + ] + +Use 'fmd batch init' to create a template batch file. + """, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + batch_parser.add_argument( + 'file', + nargs='?', # Make file optional for 'init' and 'install' subcommands + help='Path to JSON batch file, "init" to create template, or "install" to auto-find mods_dl.json' + ) + + batch_parser.add_argument( + '--output', '-o', + dest='output_path', + default=None, + help='Output directory for downloaded mods (default: from config or ./mods)' + ) + + batch_parser.add_argument( + '--include-optional', + action='store_true', + help='Include main mod optional dependencies and their required dependencies' + ) + + batch_parser.add_argument( + '--include-optional-all', + action='store_true', + dest='include_optional_all', + help='Include all optional dependencies recursively (nested optional deps)' + ) + + batch_parser.add_argument( + '--factorio-version', + dest='factorio_version', + default='2.0', + help='Target Factorio version for compatibility (default: 2.0)' + ) + + batch_parser.add_argument( + '--continue-on-error', + action='store_true', + help='Continue processing remaining mods even if one fails' + ) + + batch_parser.add_argument( + '--enabled', + action='store_true', + default=True, + help='Add mods to mod-list.json as enabled (default)' + ) + + batch_parser.add_argument( + '--disabled', + action='store_true', + help='Add mods to mod-list.json as disabled' + ) + + +def _add_check_updates_subcommand(subparsers): + """Add check-updates subcommand to parser. + + Args: + subparsers: Subparsers object to add command to. + """ + check_updates_parser = subparsers.add_parser( + 'check-updates', + help='Check for mod updates in a directory', + description='Scan a directory for installed mods and check if newer versions are available.' + ) + + check_updates_parser.add_argument( + 'directory', + help='Directory containing mod files to check' + ) + + +def _add_update_subcommand(subparsers): + """Add update subcommand to parser. + + Args: + subparsers: Subparsers object to add command to. + """ + update_parser = subparsers.add_parser( + 'update', + help='Update mods to latest versions', + description='Download newer versions of installed mods.' + ) + + update_parser.add_argument( + 'directory', + help='Directory containing mod files to update' + ) + + update_parser.add_argument( + '--update-mod', + dest='mod_name', + default=None, + help='Update only the specified mod by name' + ) + + update_parser.add_argument( + '--replace', + action='store_true', + help='Replace old versions (default: keep both old and new)' + ) + + +def _add_config_subcommand(subparsers): + """Add config subcommand to parser. + + Args: + subparsers: Subparsers object to add command to. + """ + config_parser = subparsers.add_parser( + 'config', + help='Manage configuration', + description="""Initialize, view, or modify configuration settings. + +Configuration is stored at: ~/.fmd/config.yaml + +Available configuration keys: + - default_output_path: Default directory for downloads (default: ./mods) + - include_optional_deps: Include optional dependencies (default: false) + - max_retries: Maximum retry attempts (default: 3) + - retry_delay: Delay between retries in seconds (default: 2) + - log_level: Logging level (default: INFO) + - concurrent_downloads: Number of concurrent downloads (default: 3) + +Examples: + fmd config init + fmd config set default_output_path ./my-mods + fmd config get max_retries + fmd config list + """, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + config_subparsers = config_parser.add_subparsers( + dest='config_action', + help='Configuration actions', + required=True + ) + + # config init + config_subparsers.add_parser( + 'init', + help='Initialize default configuration file' + ) + + # config get + config_get_parser = config_subparsers.add_parser( + 'get', + help='Get a configuration value' + ) + config_get_parser.add_argument( + 'key', + help='Configuration key to retrieve' + ) + + # config set + config_set_parser = config_subparsers.add_parser( + 'set', + help='Set a configuration value' + ) + config_set_parser.add_argument( + 'key', + help='Configuration key to set' + ) + config_set_parser.add_argument( + 'value', + help='Value to set' + ) + + # config list + config_subparsers.add_parser( + 'list', + help='List all configuration values' + ) + + +def _add_validate_subcommand(subparsers): + """Add validate subcommand to parser. + + Args: + subparsers: Subparsers object to add command to. + """ + validate_parser = subparsers.add_parser( + 'validate', + help='Validate downloaded mod files', + description='Check the integrity of downloaded mod files in a directory.' + ) + + validate_parser.add_argument( + 'directory', + help='Directory containing mod files to validate' + ) + + +def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: + """Parse command-line arguments. + + Args: + args: List of arguments to parse. If None, uses sys.argv[1:]. + + Returns: + Parsed arguments namespace. + """ + # If no arguments provided, use sys.argv + if args is None: + args = sys.argv[1:] + + # Check for detailed help first + if check_for_detailed_help(args): + sys.exit(0) + + parser = create_parser() + + # If no arguments provided, show simple help + if len(args) == 0: + print_simple_help() + sys.exit(0) + + # Parse arguments + parsed = parser.parse_args(args) + + # Handle custom help flag + if hasattr(parsed, 'help') and parsed.help: + print_simple_help() + sys.exit(0) + + return parsed diff --git a/src/factorio_mod_downloader/cli/validators.py b/src/factorio_mod_downloader/cli/validators.py new file mode 100644 index 0000000..9d9eb9c --- /dev/null +++ b/src/factorio_mod_downloader/cli/validators.py @@ -0,0 +1,511 @@ +"""Input validation for CLI arguments.""" + +import re +from pathlib import Path +from typing import Tuple + +from factorio_mod_downloader.infrastructure.errors import ValidationError + + +def validate_mod_url(url: str) -> Tuple[bool, str]: + """Validate that a URL matches the expected Factorio mod portal pattern or ModID format. + + Args: + url: URL or ModID to validate. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not url: + return False, "URL cannot be empty" + + # Check if it's a ModID format (modID, modID@version, modID@latest) + if not url.startswith(('http://', 'https://')): + # ModID pattern: alphanumeric, hyphens, underscores, optional @version + modid_pattern = r'^[a-zA-Z0-9_\-]+(@[a-zA-Z0-9_\.\-]+)?$' + if re.match(modid_pattern, url): + return True, "" + else: + return False, ( + f"Invalid ModID format: {url}\n" + "Expected formats:\n" + " ModID: Krastorio2\n" + " ModID@version: Krastorio2@1.3.25\n" + " ModID@latest: Krastorio2@latest\n" + " URL: https://mods.factorio.com/mod/Krastorio2" + ) + + # Expected URL pattern: https://mods.factorio.com/mod/ + # Accept: URL-encoded characters (%20, etc), query parameters (?from=search), and paths + url_pattern = r'^https?://mods\.factorio\.com/mod/[a-zA-Z0-9_\-%]+(/[a-zA-Z0-9_\-/]*)?(\?.*)?$' + + if not re.match(url_pattern, url): + return False, ( + f"Invalid mod URL format: {url}\n" + "Expected formats:\n" + " ModID: Krastorio2\n" + " ModID@version: Krastorio2@1.3.25\n" + " ModID@latest: Krastorio2@latest\n" + " URL: https://mods.factorio.com/mod/Krastorio2" + ) + + return True, "" + + +def validate_file_path(file_path: str, must_exist: bool = True, + must_be_file: bool = False, + must_be_dir: bool = False) -> Tuple[bool, str]: + """Validate that a file path exists and is accessible. + + Args: + file_path: Path to validate. + must_exist: If True, path must exist. + must_be_file: If True, path must be a file. + must_be_dir: If True, path must be a directory. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not file_path: + return False, "Path cannot be empty" + + path = Path(file_path) + + # Check existence + if must_exist and not path.exists(): + return False, f"Path does not exist: {file_path}" + + # Check if it's a file + if must_be_file and path.exists() and not path.is_file(): + return False, f"Path is not a file: {file_path}" + + # Check if it's a directory + if must_be_dir and path.exists() and not path.is_dir(): + return False, f"Path is not a directory: {file_path}" + + # Check if path is accessible (readable) + if path.exists(): + try: + # Try to access the path + if path.is_file(): + # Try to open file for reading + with open(path, 'r') as f: + pass + elif path.is_dir(): + # Try to list directory + list(path.iterdir()) + except PermissionError: + return False, f"Permission denied: {file_path}" + except Exception as e: + return False, f"Cannot access path: {file_path} ({e})" + + return True, "" + + +def validate_directory_path(dir_path: str, must_exist: bool = True, + create_if_missing: bool = False) -> Tuple[bool, str]: + """Validate a directory path. + + Args: + dir_path: Directory path to validate. + must_exist: If True, directory must exist. + create_if_missing: If True, create directory if it doesn't exist. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not dir_path: + return False, "Directory path cannot be empty" + + path = Path(dir_path) + + # If directory doesn't exist + if not path.exists(): + if create_if_missing: + try: + path.mkdir(parents=True, exist_ok=True) + return True, "" + except Exception as e: + return False, f"Cannot create directory: {dir_path} ({e})" + elif must_exist: + return False, f"Directory does not exist: {dir_path}" + else: + return True, "" + + # Check if it's actually a directory + if not path.is_dir(): + return False, f"Path is not a directory: {dir_path}" + + # Check if directory is accessible + try: + list(path.iterdir()) + except PermissionError: + return False, f"Permission denied: {dir_path}" + except Exception as e: + return False, f"Cannot access directory: {dir_path} ({e})" + + return True, "" + + +def validate_batch_file(file_path: str) -> Tuple[bool, str]: + """Validate a batch file containing mod URLs (JSON format). + + Args: + file_path: Path to batch file (must be .json). + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + # First validate that it's a readable file + is_valid, error = validate_file_path(file_path, must_exist=True, must_be_file=True) + if not is_valid: + return False, error + + # Check file extension + path = Path(file_path) + if path.suffix.lower() != '.json': + return False, ( + f"Batch file must be a JSON file (.json extension): {file_path}\n" + "Example: mods.json" + ) + + # Try to read and validate JSON content + try: + import json + with open(file_path, 'r', encoding='utf-8') as f: + batch_data = json.load(f) + + # Extract URLs based on format + urls = [] + if isinstance(batch_data, dict): + if 'mods' not in batch_data: + return False, ( + "JSON batch file must contain a 'mods' array.\n" + "Example format:\n" + '{\n' + ' "mods": [\n' + ' "https://mods.factorio.com/mod/Krastorio2",\n' + ' "https://mods.factorio.com/mod/space-exploration"\n' + ' ]\n' + '}' + ) + mods_list = batch_data['mods'] + if not isinstance(mods_list, list): + return False, "'mods' must be an array" + + for item in mods_list: + if isinstance(item, str): + urls.append(item) + elif isinstance(item, dict) and 'url' in item: + urls.append(item['url']) + + elif isinstance(batch_data, list): + for item in batch_data: + if isinstance(item, str): + urls.append(item) + elif isinstance(item, dict) and 'url' in item: + urls.append(item['url']) + else: + return False, "JSON must be an object with 'mods' array or a direct array" + + # Check if any URLs found + if not urls: + return False, f"Batch file contains no valid mod URLs: {file_path}" + + # Validate each URL + invalid_urls = [] + for i, url in enumerate(urls, 1): + is_valid_url, _ = validate_mod_url(url) + if not is_valid_url: + invalid_urls.append((i, url)) + + if invalid_urls: + error_lines = "\n".join( + f" Item {idx}: {url}" + for idx, url in invalid_urls[:5] # Show first 5 errors + ) + if len(invalid_urls) > 5: + error_lines += f"\n ... and {len(invalid_urls) - 5} more" + + return False, ( + f"Batch file contains invalid URLs:\n{error_lines}\n" + "Expected format: https://mods.factorio.com/mod/" + ) + + return True, "" + + except json.JSONDecodeError as e: + return False, f"Invalid JSON format: {e}" + except UnicodeDecodeError: + return False, f"Cannot read batch file (encoding error): {file_path}" + except Exception as e: + return False, f"Error reading batch file: {file_path} ({e})" + + +def validate_positive_integer(value: int, name: str, min_value: int = 1) -> Tuple[bool, str]: + """Validate that a value is a positive integer. + + Args: + value: Value to validate. + name: Name of the parameter (for error messages). + min_value: Minimum allowed value. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not isinstance(value, int): + return False, f"{name} must be an integer, got {type(value).__name__}" + + if value < min_value: + return False, f"{name} must be at least {min_value}, got {value}" + + return True, "" + + +def validate_and_raise(is_valid: bool, error_message: str): + """Helper to raise ValidationError if validation failed. + + Args: + is_valid: Whether validation passed. + error_message: Error message if validation failed. + + Raises: + ValidationError: If validation failed. + """ + if not is_valid: + raise ValidationError(error_message) + + + if not re.match(pattern, url): + return False, ( + f"Invalid mod URL format: {url}\n" + "Expected format: https://mods.factorio.com/mod/\n" + "Example: https://mods.factorio.com/mod/Krastorio2" + ) + + return True, "" + + +def validate_file_path(file_path: str, must_exist: bool = True, + must_be_file: bool = False, + must_be_dir: bool = False) -> Tuple[bool, str]: + """Validate that a file path exists and is accessible. + + Args: + file_path: Path to validate. + must_exist: If True, path must exist. + must_be_file: If True, path must be a file. + must_be_dir: If True, path must be a directory. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not file_path: + return False, "Path cannot be empty" + + path = Path(file_path) + + # Check existence + if must_exist and not path.exists(): + return False, f"Path does not exist: {file_path}" + + # Check if it's a file + if must_be_file and path.exists() and not path.is_file(): + return False, f"Path is not a file: {file_path}" + + # Check if it's a directory + if must_be_dir and path.exists() and not path.is_dir(): + return False, f"Path is not a directory: {file_path}" + + # Check if path is accessible (readable) + if path.exists(): + try: + # Try to access the path + if path.is_file(): + # Try to open file for reading + with open(path, 'r') as f: + pass + elif path.is_dir(): + # Try to list directory + list(path.iterdir()) + except PermissionError: + return False, f"Permission denied: {file_path}" + except Exception as e: + return False, f"Cannot access path: {file_path} ({e})" + + return True, "" + + +def validate_directory_path(dir_path: str, must_exist: bool = True, + create_if_missing: bool = False) -> Tuple[bool, str]: + """Validate a directory path. + + Args: + dir_path: Directory path to validate. + must_exist: If True, directory must exist. + create_if_missing: If True, create directory if it doesn't exist. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not dir_path: + return False, "Directory path cannot be empty" + + path = Path(dir_path) + + # If directory doesn't exist + if not path.exists(): + if create_if_missing: + try: + path.mkdir(parents=True, exist_ok=True) + return True, "" + except Exception as e: + return False, f"Cannot create directory: {dir_path} ({e})" + elif must_exist: + return False, f"Directory does not exist: {dir_path}" + else: + return True, "" + + # Check if it's actually a directory + if not path.is_dir(): + return False, f"Path is not a directory: {dir_path}" + + # Check if directory is accessible + try: + list(path.iterdir()) + except PermissionError: + return False, f"Permission denied: {dir_path}" + except Exception as e: + return False, f"Cannot access directory: {dir_path} ({e})" + + return True, "" + + +def validate_batch_file(file_path: str) -> Tuple[bool, str]: + """Validate a batch file containing mod URLs (JSON format). + + Args: + file_path: Path to batch file (must be .json). + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + # First validate that it's a readable file + is_valid, error = validate_file_path(file_path, must_exist=True, must_be_file=True) + if not is_valid: + return False, error + + # Check file extension + path = Path(file_path) + if path.suffix.lower() != '.json': + return False, ( + f"Batch file must be a JSON file (.json extension): {file_path}\n" + "Example: mods.json" + ) + + # Try to read and validate JSON content + try: + import json + with open(file_path, 'r', encoding='utf-8') as f: + batch_data = json.load(f) + + # Extract URLs based on format + urls = [] + if isinstance(batch_data, dict): + if 'mods' not in batch_data: + return False, ( + "JSON batch file must contain a 'mods' array.\n" + "Example format:\n" + '{\n' + ' "mods": [\n' + ' "https://mods.factorio.com/mod/Krastorio2",\n' + ' "https://mods.factorio.com/mod/space-exploration"\n' + ' ]\n' + '}' + ) + mods_list = batch_data['mods'] + if not isinstance(mods_list, list): + return False, "'mods' must be an array" + + for item in mods_list: + if isinstance(item, str): + urls.append(item) + elif isinstance(item, dict) and 'url' in item: + urls.append(item['url']) + + elif isinstance(batch_data, list): + for item in batch_data: + if isinstance(item, str): + urls.append(item) + elif isinstance(item, dict) and 'url' in item: + urls.append(item['url']) + else: + return False, "JSON must be an object with 'mods' array or a direct array" + + # Check if any URLs found + if not urls: + return False, f"Batch file contains no valid mod URLs: {file_path}" + + # Validate each URL + invalid_urls = [] + for i, url in enumerate(urls, 1): + is_valid_url, _ = validate_mod_url(url) + if not is_valid_url: + invalid_urls.append((i, url)) + + if invalid_urls: + error_lines = "\n".join( + f" Item {idx}: {url}" + for idx, url in invalid_urls[:5] # Show first 5 errors + ) + if len(invalid_urls) > 5: + error_lines += f"\n ... and {len(invalid_urls) - 5} more" + + return False, ( + f"Batch file contains invalid URLs:\n{error_lines}\n" + "Expected format: https://mods.factorio.com/mod/" + ) + + return True, "" + + except json.JSONDecodeError as e: + return False, f"Invalid JSON format: {e}" + except UnicodeDecodeError: + return False, f"Cannot read batch file (encoding error): {file_path}" + except Exception as e: + return False, f"Error reading batch file: {file_path} ({e})" + + +def validate_positive_integer(value: int, name: str, min_value: int = 1) -> Tuple[bool, str]: + """Validate that a value is a positive integer. + + Args: + value: Value to validate. + name: Name of the parameter (for error messages). + min_value: Minimum allowed value. + + Returns: + Tuple of (is_valid, error_message). error_message is empty if valid. + """ + if not isinstance(value, int): + return False, f"{name} must be an integer, got {type(value).__name__}" + + if value < min_value: + return False, f"{name} must be at least {min_value}, got {value}" + + return True, "" + + +def validate_and_raise(is_valid: bool, error_message: str): + """Helper to raise ValidationError if validation failed. + + Args: + is_valid: Whether validation passed. + error_message: Error message if validation failed. + + Raises: + ValidationError: If validation failed. + """ + if not is_valid: + raise ValidationError(error_message) + diff --git a/src/factorio_mod_downloader/core/__init__.py b/src/factorio_mod_downloader/core/__init__.py new file mode 100644 index 0000000..a66966d --- /dev/null +++ b/src/factorio_mod_downloader/core/__init__.py @@ -0,0 +1,28 @@ +"""Core module for Factorio Mod Downloader.""" + +from factorio_mod_downloader.core.dependency_resolver import ( + DependencyResolver, + DependencyTree, +) +from factorio_mod_downloader.core.downloader import ( + CoreDownloader, + DownloadPlan, + DownloadResult, +) +from factorio_mod_downloader.core.file_downloader import ( + DownloadFileResult, + FileDownloader, +) +from factorio_mod_downloader.core.mod_info_fetcher import ModInfo, ModInfoFetcher + +__all__ = [ + 'CoreDownloader', + 'DependencyResolver', + 'DependencyTree', + 'DownloadPlan', + 'DownloadResult', + 'DownloadFileResult', + 'FileDownloader', + 'ModInfo', + 'ModInfoFetcher', +] diff --git a/src/factorio_mod_downloader/core/dependency_resolver.py b/src/factorio_mod_downloader/core/dependency_resolver.py new file mode 100644 index 0000000..2f2305d --- /dev/null +++ b/src/factorio_mod_downloader/core/dependency_resolver.py @@ -0,0 +1,215 @@ +"""Dependency resolution for Factorio mods.""" + +from dataclasses import dataclass +from typing import List, Set + +from factorio_mod_downloader.core.mod_info_fetcher import ModInfo, ModInfoFetcher +from factorio_mod_downloader.downloader.helpers import generate_anticache + + +# API Constants +BASE_DOWNLOAD_URL = "https://mods-storage.re146.dev" + +# Reserved dependencies that should be skipped +RESERVED_DEPENDENCIES = {"space-age"} + + +@dataclass +class DependencyTree: + """Tree structure representing mod dependencies.""" + root: ModInfo + dependencies: List['DependencyTree'] + + +class DependencyResolver: + """Resolves mod dependencies recursively. + + This class extracts the dependency resolution logic from ModDownloader + and makes it independent of GUI callbacks. + """ + + def __init__(self, logger, config): + """Initialize DependencyResolver. + + Args: + logger: LoggerSystem instance for logging + config: Config object with settings + """ + self.logger = logger + self.config = config + self.mod_info_fetcher = ModInfoFetcher(logger, config) + self.analyzed_mods: Set[str] = set() + + def resolve_dependencies( + self, + mod_url: str, + include_optional: bool = False + ) -> DependencyTree: + """Resolve all dependencies for a mod recursively. + + Args: + mod_url: URL of the mod to analyze + include_optional: Whether to include optional dependencies + + Returns: + DependencyTree with the mod and all its dependencies + + Raises: + Exception: If mod information cannot be fetched + """ + # Initialize Selenium if not already done + if not self.mod_info_fetcher.chrome_options: + self.mod_info_fetcher.init_selenium() + + # Reset analyzed mods for new resolution + self.analyzed_mods.clear() + + # Resolve the dependency tree + return self._resolve_recursive(mod_url, include_optional) + + def _resolve_recursive( + self, + mod_url: str, + include_optional: bool, + is_optional: bool = False + ) -> DependencyTree: + """Recursively resolve dependencies for a mod. + + Args: + mod_url: URL of the mod to analyze + include_optional: Whether to include optional dependencies + is_optional: Whether this mod is an optional dependency + + Returns: + DependencyTree for this mod + + Raises: + Exception: If mod information cannot be fetched + """ + # Mark as analyzed to avoid circular dependencies + if mod_url in self.analyzed_mods: + self.logger.debug(f"Mod already analyzed: {mod_url}") + # Return a placeholder - will be filtered out later + return None + + self.analyzed_mods.add(mod_url) + + # Fetch mod information + self.logger.debug(f"Analyzing mod: {mod_url}") + soup = self.mod_info_fetcher.get_page_source(mod_url) + + if not soup: + raise Exception(f"Could not fetch page for {mod_url}") + + # Extract mod details + mod_name = self.mod_info_fetcher.get_mod_name(soup) + latest_version = self.mod_info_fetcher.get_latest_version(soup) + + if not mod_name or not latest_version: + raise Exception(f"Could not get mod info for {mod_url}") + + # Skip reserved dependencies + if mod_name in RESERVED_DEPENDENCIES: + self.logger.warning( + f"Skipping reserved dependency {mod_name}. " + "Download manually if needed." + ) + return None + + self.logger.info( + f"Loaded mod {mod_name} with version {latest_version}", + mod=mod_name, + version=latest_version + ) + + # Construct download URL + download_url = ( + f"{BASE_DOWNLOAD_URL}/{mod_name}/{latest_version}.zip" + f"?anticache={generate_anticache()}" + ) + + # Create ModInfo for this mod + mod_info = ModInfo( + name=mod_name, + version=latest_version, + url=mod_url, + download_url=download_url, + is_optional=is_optional + ) + + # Fetch dependencies + self.logger.info(f"Loading dependencies for {mod_name}") + dependencies = self.mod_info_fetcher.get_required_dependencies( + mod_name, + include_optional + ) + + if not dependencies: + self.logger.info(f"No dependencies found for {mod_name}") + return DependencyTree(root=mod_info, dependencies=[]) + + dep_names = ", ".join([dep_name for dep_name, _ in dependencies]) + self.logger.info( + f"Dependencies found for {mod_name}: {dep_names}", + mod=mod_name, + dependencies=dep_names, + count=len(dependencies) + ) + + # Recursively resolve dependencies + dep_trees = [] + for dep_name, dep_url in dependencies: + if dep_url in self.analyzed_mods: + self.logger.debug(f"Dependency already analyzed: {dep_name}") + continue + + self.logger.info(f"Analyzing dependency {dep_name} of {mod_name}") + + try: + dep_tree = self._resolve_recursive(dep_url, include_optional, is_optional=False) + if dep_tree: # Filter out None (reserved or circular dependencies) + dep_trees.append(dep_tree) + except Exception as e: + self.logger.error( + f"Error resolving dependency {dep_name}: {e}", + mod=mod_name, + dependency=dep_name + ) + # Continue with other dependencies + + return DependencyTree(root=mod_info, dependencies=dep_trees) + + def get_download_list(self, tree: DependencyTree) -> List[ModInfo]: + """Flatten dependency tree into download list. + + The list is ordered such that dependencies come before the mods + that depend on them (depth-first traversal). + + Args: + tree: DependencyTree to flatten + + Returns: + List of ModInfo objects in download order + """ + if not tree: + return [] + + download_list = [] + seen_mods = set() + + def _traverse(node: DependencyTree): + """Depth-first traversal to build download list.""" + if not node: + return + + # Process dependencies first + for dep in node.dependencies: + _traverse(dep) + + # Add this mod if not already seen + if node.root.name not in seen_mods: + download_list.append(node.root) + seen_mods.add(node.root.name) + + _traverse(tree) + return download_list diff --git a/src/factorio_mod_downloader/core/downloader.py b/src/factorio_mod_downloader/core/downloader.py new file mode 100644 index 0000000..f7bcd71 --- /dev/null +++ b/src/factorio_mod_downloader/core/downloader.py @@ -0,0 +1,241 @@ +"""Core download engine for Factorio mods. + +This module provides the main download orchestration that is independent +of both GUI and CLI interfaces. +""" + +import os +import time +from dataclasses import dataclass +from typing import Callable, List, Optional, Set, Tuple + +from factorio_mod_downloader.core.dependency_resolver import DependencyResolver +from factorio_mod_downloader.core.file_downloader import FileDownloader +from factorio_mod_downloader.core.mod_info_fetcher import ModInfo, ModInfoFetcher + + +@dataclass +class DownloadResult: + """Result of a download operation.""" + success: bool + downloaded_mods: List[str] + failed_mods: List[Tuple[str, str]] # (mod_name, error) + total_size: int # bytes + duration: float # seconds + + +@dataclass +class DownloadPlan: + """Plan for a download operation (for dry-run).""" + mods_to_download: List[ModInfo] + total_count: int + estimated_size: int # bytes (if available) + + +class CoreDownloader: + """Core download engine independent of UI. + + This class orchestrates the download process using ModInfoFetcher, + DependencyResolver, and FileDownloader. It supports progress callbacks + for UI updates and returns structured results. + """ + + def __init__( + self, + output_path: str, + include_optional: bool, + logger, + config, + progress_callback: Optional[Callable] = None + ): + """Initialize CoreDownloader. + + Args: + output_path: Directory to save downloaded mods + include_optional: Whether to include optional dependencies + logger: LoggerSystem instance for logging + config: Config object with settings + progress_callback: Optional callback for progress updates. + Called with (event_type, data) where event_type is: + - 'analyzing': data = mod_name + - 'downloading': data = (mod_name, percentage, downloaded_mb, total_mb, speed_mbps) + - 'complete': data = mod_name + - 'error': data = (mod_name, error_message) + """ + self.output_path = output_path + self.include_optional = include_optional + self.logger = logger + self.config = config + self.progress_callback = progress_callback + + # Initialize components + self.mod_info_fetcher = ModInfoFetcher(logger, config) + self.dependency_resolver = DependencyResolver(logger, config) + self.file_downloader = FileDownloader(logger, config) + + # Track downloaded mods to avoid duplicates + self.downloaded_mods: Set[str] = set() + + def download_mod(self, mod_url: str) -> DownloadResult: + """Download a mod and its dependencies. + + Args: + mod_url: URL of the mod to download + + Returns: + DownloadResult with download statistics + """ + start_time = time.time() + downloaded = [] + failed = [] + total_size = 0 + + try: + # Notify analyzing + if self.progress_callback: + self.progress_callback('analyzing', mod_url.split('/')[-1]) + + # Resolve dependencies + self.logger.info(f"Resolving dependencies for {mod_url}") + dep_tree = self.dependency_resolver.resolve_dependencies( + mod_url, + self.include_optional + ) + + # Get download list + download_list = self.dependency_resolver.get_download_list(dep_tree) + + if not download_list: + self.logger.warning("No mods to download") + return DownloadResult( + success=True, + downloaded_mods=[], + failed_mods=[], + total_size=0, + duration=time.time() - start_time + ) + + self.logger.info( + f"Found {len(download_list)} mods to download", + count=len(download_list) + ) + + # Download each mod + for mod_info in download_list: + file_name = f"{mod_info.name}_{mod_info.version}.zip" + + # Skip if already downloaded + if file_name in self.downloaded_mods: + self.logger.info(f"Mod already downloaded: {file_name}") + continue + + file_path = os.path.join(self.output_path, file_name) + + # Skip if file already exists + if os.path.exists(file_path): + self.logger.info(f"File already exists: {file_path}") + self.downloaded_mods.add(file_name) + downloaded.append(file_name) + continue + + # Download the mod + self.logger.info(f"Downloading {file_name}") + + # Create progress callback for this specific file + def file_progress_callback(percentage, downloaded_mb, total_mb, speed): + if self.progress_callback: + self.progress_callback( + 'downloading', + (mod_info.name, percentage, downloaded_mb, total_mb, speed) + ) + + result = self.file_downloader.download_file( + mod_info.download_url, + file_path, + progress_callback=file_progress_callback, + resume=True + ) + + if result.success: + self.downloaded_mods.add(file_name) + downloaded.append(file_name) + total_size += result.size + + if self.progress_callback: + self.progress_callback('complete', mod_info.name) + else: + failed.append((mod_info.name, result.error or "Unknown error")) + + if self.progress_callback: + self.progress_callback('error', (mod_info.name, result.error)) + + duration = time.time() - start_time + success = len(failed) == 0 + + return DownloadResult( + success=success, + downloaded_mods=downloaded, + failed_mods=failed, + total_size=total_size, + duration=duration + ) + + except Exception as e: + self.logger.error(f"Error during download: {e}") + duration = time.time() - start_time + + if self.progress_callback: + self.progress_callback('error', (mod_url.split('/')[-1], str(e))) + + return DownloadResult( + success=False, + downloaded_mods=downloaded, + failed_mods=failed + [(mod_url.split('/')[-1], str(e))], + total_size=total_size, + duration=duration + ) + + def get_download_plan(self, mod_url: str) -> DownloadPlan: + """Get download plan without downloading (for dry-run). + + Analyzes dependencies and calculates what would be downloaded + without actually performing the downloads. + + Args: + mod_url: URL of the mod to analyze + + Returns: + DownloadPlan with list of mods and estimated size + """ + try: + self.logger.info(f"Creating download plan for {mod_url}") + + # Resolve dependencies + dep_tree = self.dependency_resolver.resolve_dependencies( + mod_url, + self.include_optional + ) + + # Get download list + download_list = self.dependency_resolver.get_download_list(dep_tree) + + # Calculate estimated size (if available) + estimated_size = sum( + mod.size for mod in download_list if mod.size is not None + ) + + self.logger.info( + f"Download plan created", + mod_count=len(download_list), + estimated_size_mb=f"{estimated_size / 1024 / 1024:.2f}" if estimated_size else "unknown" + ) + + return DownloadPlan( + mods_to_download=download_list, + total_count=len(download_list), + estimated_size=estimated_size + ) + + except Exception as e: + self.logger.error(f"Error creating download plan: {e}") + raise diff --git a/src/factorio_mod_downloader/core/file_downloader.py b/src/factorio_mod_downloader/core/file_downloader.py new file mode 100644 index 0000000..1c99a46 --- /dev/null +++ b/src/factorio_mod_downloader/core/file_downloader.py @@ -0,0 +1,266 @@ +"""File download with progress tracking and retry logic.""" + +import os +import time +from dataclasses import dataclass +from typing import Callable, Optional + +import requests + +from factorio_mod_downloader.infrastructure.recovery import RecoveryManager +from factorio_mod_downloader.infrastructure.errors import ( + NetworkError, + FileSystemError, + is_retryable_error, + categorize_error, + ErrorCategory +) + + +@dataclass +class DownloadFileResult: + """Result of a single file download.""" + success: bool + file_path: str + size: int + duration: float + error: Optional[str] = None + + +class FileDownloader: + """Handles file downloads with progress and retry. + + This class extracts the file download logic from ModDownloader + and makes it independent of GUI callbacks. It supports: + - Progress callbacks for UI updates + - Retry logic with configurable max_retries + - Resume support via RecoveryManager + - Generic progress callbacks (not GUI-specific) + """ + + def __init__(self, logger, config): + """Initialize FileDownloader. + + Args: + logger: LoggerSystem instance for logging + config: Config object with retry settings + """ + self.logger = logger + self.config = config + self.recovery_manager = RecoveryManager(logger, config) + + def download_file( + self, + url: str, + output_path: str, + progress_callback: Optional[Callable] = None, + resume: bool = True + ) -> DownloadFileResult: + """Download a file with progress tracking and retry. + + Args: + url: File URL to download + output_path: Local path to save file + progress_callback: Optional callback for progress updates. + Called with (percentage, downloaded_mb, total_mb, speed_mbps) + resume: Whether to attempt resuming partial downloads + + Returns: + DownloadFileResult with download status and statistics + """ + file_name = os.path.basename(output_path) + self.logger.info(f"Starting download", file=file_name, url=url) + + start_time = time.time() + + # Attempt download with retry + result = self._download_with_retry( + url, + output_path, + progress_callback, + resume, + self.config.max_retries + ) + + duration = time.time() - start_time + + if result['success']: + file_size = os.path.getsize(output_path) + self.logger.info( + f"Download completed successfully", + file=file_name, + size_mb=f"{file_size / 1024 / 1024:.2f}", + duration_sec=f"{duration:.2f}" + ) + return DownloadFileResult( + success=True, + file_path=output_path, + size=file_size, + duration=duration + ) + else: + self.logger.error( + f"Download failed after {self.config.max_retries} attempts", + file=file_name, + error=result['error'] + ) + return DownloadFileResult( + success=False, + file_path=output_path, + size=0, + duration=duration, + error=result['error'] + ) + + def _download_with_retry( + self, + url: str, + output_path: str, + progress_callback: Optional[Callable], + resume: bool, + max_retries: int + ) -> dict: + """Download with automatic retry on failure. + + Args: + url: File URL to download + output_path: Local path to save file + progress_callback: Optional callback for progress updates + resume: Whether to attempt resuming partial downloads + max_retries: Maximum number of retry attempts + + Returns: + Dictionary with 'success' (bool) and 'error' (str) keys + """ + file_name = os.path.basename(output_path) + last_error = None + + for attempt in range(1, max_retries + 1): + try: + # Check if we can resume + resume_position = 0 + if resume and self.recovery_manager.can_resume(output_path, url): + resume_position = self.recovery_manager.get_resume_position(output_path) + self.logger.info( + f"Resuming download from byte {resume_position}", + file=file_name + ) + + # Prepare headers for resume + headers = {} + if resume_position > 0: + headers['Range'] = f'bytes={resume_position}-' + + # Make request + response = requests.get(url, stream=True, headers=headers, timeout=30) + response.raise_for_status() + + # Get total size + if resume_position > 0 and response.status_code == 206: + # Partial content - get size from Content-Range header + content_range = response.headers.get('Content-Range', '') + if content_range: + # Format: "bytes start-end/total" + total_size = int(content_range.split('/')[-1]) + else: + total_size = int(response.headers.get('content-length', 0)) + resume_position + else: + total_size = int(response.headers.get('content-length', 0)) + + # Determine chunk size + min_chunk = 64 * 1024 # 64 KB + max_chunk = 4 * 1024 * 1024 # 4 MB + block_size = max(min_chunk, min(total_size // 100, max_chunk)) if total_size else min_chunk + + # Open file for writing (append if resuming) + mode = 'ab' if resume_position > 0 else 'wb' + progress = resume_position + + # Ensure directory exists + try: + os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + except OSError as e: + # Fail fast for filesystem errors + raise FileSystemError( + f"Cannot create output directory: {e}", + suggestion="Check directory permissions and available disk space" + ) + + with open(output_path, mode) as file: + download_start_time = time.time() + last_update = download_start_time + + for chunk in response.iter_content(chunk_size=block_size): + if not chunk: + continue + + file.write(chunk) + progress += len(chunk) + + # Calculate progress + percentage = progress / total_size if total_size else 0 + now = time.time() + + # Update progress every ~0.2s + if progress_callback and (now - last_update >= 0.2 or progress >= total_size): + elapsed = now - download_start_time + speed = (progress / 1024 / 1024) / elapsed if elapsed > 0 else 0.0 # MB/s + + downloaded_mb = progress / 1024 / 1024 + total_mb = total_size / 1024 / 1024 if total_size else 0 + + progress_callback(percentage, downloaded_mb, total_mb, speed) + last_update = now + + # Download successful + return {'success': True, 'error': None} + + except (PermissionError, OSError) as e: + # Filesystem errors - fail fast, don't retry + error_category = categorize_error(e) + if error_category == ErrorCategory.FILESYSTEM: + error_msg = f"Filesystem error: {e}" + self.logger.error( + f"Filesystem error (not retrying)", + file=file_name, + error=error_msg + ) + return {'success': False, 'error': error_msg} + # If not categorized as filesystem, treat as retryable + last_error = e + + except Exception as e: + last_error = e + error_msg = str(e) + + # Check if error is retryable + if not is_retryable_error(e): + # Non-retryable error - fail fast + self.logger.error( + f"Non-retryable error", + file=file_name, + error=error_msg + ) + return {'success': False, 'error': error_msg} + + # Delete partial file on error (unless we're going to retry with resume) + if os.path.exists(output_path) and not resume: + try: + os.remove(output_path) + except: + pass + + # Retry logic for retryable errors + if attempt < max_retries: + self.logger.warning( + f"Download retry attempt {attempt}/{max_retries}", + file=file_name, + error=error_msg + ) + time.sleep(self.config.retry_delay) + else: + return {'success': False, 'error': error_msg} + + # Max retries exceeded + final_error = str(last_error) if last_error else 'Max retries exceeded' + return {'success': False, 'error': final_error} diff --git a/src/factorio_mod_downloader/core/mod_info_fetcher.py b/src/factorio_mod_downloader/core/mod_info_fetcher.py new file mode 100644 index 0000000..60181be --- /dev/null +++ b/src/factorio_mod_downloader/core/mod_info_fetcher.py @@ -0,0 +1,246 @@ +"""Mod information fetching and parsing for Factorio mods.""" + +import time +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +import chromedriver_autoinstaller +from bs4 import BeautifulSoup +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +from factorio_mod_downloader.downloader.helpers import find_free_port +from factorio_mod_downloader.infrastructure.errors import ( + NetworkError, + ParsingError +) + + +# API Constants +BASE_FACTORIO_MOD_URL: Final = "https://mods.factorio.com/mod" +BASE_MOD_URL: Final = "https://re146.dev/factorio/mods/en#" + + +@dataclass +class ModInfo: + """Information about a mod.""" + name: str + version: str + url: str + download_url: str + size: Optional[int] = None + is_optional: bool = False + + +class ModInfoFetcher: + """Fetches and parses mod information from the Factorio mod portal. + + This class is independent of GUI callbacks and can be used by both + CLI and GUI interfaces. + """ + + def __init__(self, logger, config): + """Initialize ModInfoFetcher. + + Args: + logger: LoggerSystem instance for logging + config: Config object with settings + """ + self.logger = logger + self.config = config + self.chrome_options: Optional[Options] = None + + def init_selenium(self) -> Options: + """Initialize Selenium WebDriver options. + + Returns: + Chrome Options object + + Raises: + Exception: If chromedriver installation fails + """ + try: + self.logger.debug("Downloading application dependencies (chromedriver)") + chromedriver_autoinstaller.install() + self.logger.debug("Finished downloading application dependencies") + + chrome_options = Options() + chrome_options.add_argument("--headless") + chrome_options.add_argument("--window-position=-2400,-2400") + chrome_options.add_argument("--disable-gpu") + + # Find and set a free debugging port + port = find_free_port() + chrome_options.add_argument(f"--remote-debugging-port={port}") + + self.logger.debug("Configured application dependencies") + self.chrome_options = chrome_options + return chrome_options + + except Exception as e: + self.logger.error(f"Error initializing Selenium: {str(e)}") + raise + + def init_driver(self) -> webdriver.Chrome: + """Initialize a Chrome WebDriver instance. + + Returns: + Chrome WebDriver instance + """ + if not self.chrome_options: + self.init_selenium() + return webdriver.Chrome(options=self.chrome_options) + + def close_driver(self, driver: webdriver.Chrome): + """Close and cleanup a WebDriver instance. + + Args: + driver: WebDriver instance to close + """ + try: + driver.stop_client() + driver.close() + driver.quit() + except Exception as e: + self.logger.warning(f"Error closing driver: {str(e)}") + + def get_page_source(self, url: str, is_dependency_check: bool = False) -> Optional[BeautifulSoup]: + """Fetch and parse a webpage. + + Args: + url: URL to fetch + is_dependency_check: Whether to wait for dependency table to load + + Returns: + BeautifulSoup object of the parsed HTML, or None on error + + Raises: + NetworkError: If page cannot be loaded due to network issues + """ + driver = self.init_driver() + + try: + driver.get(url) + + if is_dependency_check: + # Wait for dependency table to load + WebDriverWait(driver, 15).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "table.panel-hole")) + ) + else: + time.sleep(2) + + html = driver.page_source + return BeautifulSoup(html, "html.parser") + except Exception as e: + self.logger.error(f"Error loading {url}: {str(e)}") + # Raise NetworkError for retryable network issues + raise NetworkError( + f"Failed to load page: {url}", + suggestion="Check your internet connection and verify the mod URL is correct" + ) + finally: + self.close_driver(driver) + + def get_mod_name(self, soup: BeautifulSoup) -> str: + """Extract mod name from parsed HTML. + + Args: + soup: BeautifulSoup object of mod page + + Returns: + Mod name + + Raises: + ParsingError: If mod name cannot be found + """ + dd_element = soup.find("dd", id="mod-info-name") + if not dd_element: + raise ParsingError( + "Could not find mod name in page", + suggestion="The mod page format may have changed or the page failed to load completely" + ) + return dd_element.get_text(strip=True).strip() + + def get_latest_version(self, soup: BeautifulSoup) -> str: + """Extract the latest mod version from parsed HTML. + + Args: + soup: BeautifulSoup object of mod page + + Returns: + Latest version identifier + + Raises: + ParsingError: If version cannot be found + """ + select = soup.find("select", {"id": "mod-version"}) + if not select: + raise ParsingError( + "No version select element found", + suggestion="The mod page format may have changed or the page failed to load completely" + ) + + # Find the latest version (marked with 'last') + for option in select.find_all("option"): + if "(last)" in option.text: + return option["value"] + + # Fallback to first version + first_option = select.find("option") + if not first_option: + raise ParsingError( + "No version options found", + suggestion="The mod may not have any published versions" + ) + + return first_option["value"] + + def get_required_dependencies( + self, + mod_name: str, + include_optional: bool = False + ) -> List[Tuple[str, str]]: + """Fetch required dependencies for a mod. + + Args: + mod_name: Name of the mod + include_optional: Whether to include optional dependencies + + Returns: + List of (dependency_name, dependency_url) tuples + """ + dependency_url = ( + f"{BASE_FACTORIO_MOD_URL}/{mod_name}/dependencies?direction=out&sort=idx&filter=all" + ) + + try: + soup = self.get_page_source(dependency_url, is_dependency_check=True) + if not soup: + self.logger.warning(f"Could not fetch dependencies for {mod_name}") + return [] + + required_mods = [] + + # Get required dependencies + links = soup.find_all("a", class_="mod-dependencies-required") + for link in links: + dep_name = link.get_text(strip=True) + mod_url = f"{BASE_MOD_URL}{BASE_FACTORIO_MOD_URL}/{dep_name}" + required_mods.append((dep_name, mod_url)) + + # Get optional dependencies if requested + if include_optional: + for link in soup.find_all("a", class_="mod-dependencies-optional"): + dep_name = link.get_text(strip=True) + mod_url = f"{BASE_MOD_URL}{BASE_FACTORIO_MOD_URL}/{dep_name}" + required_mods.append((dep_name, mod_url)) + + return required_mods + + except Exception as e: + self.logger.error(f"Could not fetch dependencies for {mod_name}: {e}") + return [] diff --git a/src/factorio_mod_downloader/core/rust_downloader.py b/src/factorio_mod_downloader/core/rust_downloader.py new file mode 100644 index 0000000..b32255d --- /dev/null +++ b/src/factorio_mod_downloader/core/rust_downloader.py @@ -0,0 +1,172 @@ +"""Rust-powered download engine wrapper.""" + +from typing import List, Optional, Tuple +from dataclasses import dataclass + +try: + import factorio_mod_downloader_rust as rust_module + RUST_AVAILABLE = True +except ImportError: + RUST_AVAILABLE = False + + +@dataclass +class RustDownloadResult: + """Result from Rust download operation.""" + success: bool + downloaded_mods: List[str] + failed_mods: List[Tuple[str, str]] + total_size: int + duration: float + + +class RustDownloader: + """Wrapper for Rust download functions.""" + + def __init__(self, logger, config): + self.logger = logger + self.config = config + + if not RUST_AVAILABLE: + raise ImportError( + "Rust module not available. Please build the Rust extension first." + ) + + def download_mod( + self, + mod_url: str, + output_path: str, + factorio_version: str = "2.0", + include_optional: bool = True, + include_optional_all: bool = False, + target_mod_version: Optional[str] = None, + max_depth: int = 10 + ) -> RustDownloadResult: + """Download a mod with dependencies using Rust.""" + self.logger.debug(f"Using Rust downloader for: {mod_url}") + + result = rust_module.download_mod_with_deps( + mod_url=mod_url, + output_path=output_path, + factorio_version=factorio_version, + include_optional=include_optional, + include_optional_all=include_optional_all, + target_mod_version=target_mod_version, + max_depth=max_depth + ) + + return RustDownloadResult( + success=result.success, + downloaded_mods=result.downloaded_mods, + failed_mods=result.failed_mods, + total_size=result.total_size, + duration=result.duration + ) + + def batch_download( + self, + mod_urls: List[str], + output_path: str, + factorio_version: str = "2.0", + include_optional: bool = True, + include_optional_all: bool = False, + max_depth: int = 10, + continue_on_error: bool = True + ) -> RustDownloadResult: + """Batch download multiple mods using Rust.""" + self.logger.debug(f"Using Rust batch downloader for {len(mod_urls)} mods") + + result = rust_module.batch_download_mods( + mod_urls=mod_urls, + output_path=output_path, + factorio_version=factorio_version, + _include_optional=include_optional, + _include_optional_all=include_optional_all, + _max_depth=max_depth, + continue_on_error=continue_on_error + ) + + return RustDownloadResult( + success=result.success, + downloaded_mods=result.downloaded_mods, + failed_mods=result.failed_mods, + total_size=result.total_size, + duration=result.duration + ) + + def download_mod_enhanced( + self, + mod_url: str, + output_path: str, + factorio_version: str = "2.0", + include_optional: bool = True, + include_optional_all: bool = False, + target_mod_version: Optional[str] = None, + max_depth: int = 10, + update_mod_list: bool = False + ) -> RustDownloadResult: + """Download a mod with dependencies using enhanced Rust downloader with beautiful progress bars.""" + self.logger.debug(f"Using enhanced Rust downloader for: {mod_url}") + + result = rust_module.download_mod_with_deps_enhanced( + mod_url=mod_url, + output_path=output_path, + factorio_version=factorio_version, + include_optional=include_optional, + include_optional_all=include_optional_all, + target_mod_version=target_mod_version, + max_depth=max_depth, + update_mod_list=update_mod_list + ) + + return RustDownloadResult( + success=result.success, + downloaded_mods=result.downloaded_mods, + failed_mods=result.failed_mods, + total_size=result.total_size, + duration=result.duration + ) + + def batch_download_enhanced( + self, + mod_urls: List[str], + output_path: str, + factorio_version: str = "2.0", + include_optional: bool = True, + include_optional_all: bool = False, + max_depth: int = 10, + continue_on_error: bool = True + ) -> RustDownloadResult: + """Batch download multiple mods using enhanced Rust downloader with beautiful progress bars.""" + self.logger.debug(f"Using enhanced Rust batch downloader for {len(mod_urls)} mods") + + result = rust_module.batch_download_mods_enhanced( + mod_urls=mod_urls, + output_path=output_path, + factorio_version=factorio_version, + include_optional=include_optional, + include_optional_all=include_optional_all, + max_depth=max_depth, + continue_on_error=continue_on_error + ) + + return RustDownloadResult( + success=result.success, + downloaded_mods=result.downloaded_mods, + failed_mods=result.failed_mods, + total_size=result.total_size, + duration=result.duration + ) + + def update_mod_list_json( + self, + mod_names: List[str], + mods_directory: str, + enabled: bool = True + ) -> bool: + """Update mod-list.json with downloaded mods.""" + return rust_module.update_mod_list_json( + mod_names=mod_names, + mods_directory=mods_directory, + enabled=enabled + ) diff --git a/src/factorio_mod_downloader/downloader/mod_downloader.py b/src/factorio_mod_downloader/downloader/mod_downloader.py index 5e3d06b..c77502f 100644 --- a/src/factorio_mod_downloader/downloader/mod_downloader.py +++ b/src/factorio_mod_downloader/downloader/mod_downloader.py @@ -1,41 +1,32 @@ """ Main downloader module for Factorio mods with dependency resolution. + +This module provides a GUI-compatible wrapper around CoreDownloader. """ import os -import time from threading import Thread from typing import Final -from typing import List -from typing import Set -from typing import Tuple -import chromedriver_autoinstaller -import requests -from bs4 import BeautifulSoup from CTkMessagebox import CTkMessagebox -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait -from factorio_mod_downloader.downloader.helpers import find_free_port -from factorio_mod_downloader.downloader.helpers import generate_anticache -from factorio_mod_downloader.downloader.helpers import is_port_free -from factorio_mod_downloader.downloader.helpers import is_website_up +from factorio_mod_downloader.core.downloader import CoreDownloader +from factorio_mod_downloader.infrastructure.config import Config +from factorio_mod_downloader.infrastructure.logger import LoggerSystem # API Constants BASE_FACTORIO_MOD_URL: Final = "https://mods.factorio.com/mod" BASE_MOD_URL: Final = "https://re146.dev/factorio/mods/en#" -BASE_DOWNLOAD_URL: Final = "https://mods-storage.re146.dev" class ModDownloader(Thread): - """Thread-based mod downloader with dependency resolution.""" + """Thread-based mod downloader with dependency resolution. + + This class wraps CoreDownloader to provide GUI-compatible functionality. + """ - def __init__(self, mod_url: str, output_path: str, app): + def __init__(self, mod_url: str, output_path: str, app, logger=None, config=None): """ Initialize the mod downloader. @@ -43,6 +34,8 @@ def __init__(self, mod_url: str, output_path: str, app): mod_url: URL of the mod to download output_path: Directory to save downloaded mods app: Reference to the GUI application + logger: Optional LoggerSystem instance for structured logging + config: Optional Config instance for settings """ super().__init__() self.daemon = True @@ -50,50 +43,86 @@ def __init__(self, mod_url: str, output_path: str, app): self.mod = mod_url.split("/")[-1] # Extract mod name from URL self.mod_url = BASE_MOD_URL + mod_url self.app = app - self.downloaded_mods: Set[str] = set() - self.analyzed_mods: Set[str] = set() - self.chrome_options: Options = None - self.download_threads = [] + + # Initialize logger if not provided + if logger is None: + self.logger = LoggerSystem(log_level='INFO') + else: + self.logger = logger + + # Initialize config if not provided + if config is None: + self.config = Config() + else: + self.config = config + self.include_optional = self.app.optional_deps.get() + + # Track active download entries for progress updates + self.active_downloads = {} def run(self): - """Execute the download process.""" + """Execute the download process using CoreDownloader.""" try: self.log_info(f"Loading mod {self.mod}.\n") - - if not is_website_up(BASE_MOD_URL): - raise Exception("Website down. Please check your connection.") - - self.chrome_options = self._init_selenium() - self.download_mod_with_dependencies(self.mod_url, self.output_path) - - active_threads = [t for t in self.download_threads if t.is_alive()] - if active_threads: - self.log_info("Waiting for all downloads to finish...\n") - self.app.progress_file.after( - 0, lambda: self.app.progress_file.configure(text="Finalizing downloads...") - ) - - for t in active_threads: - t.join() - - self.log_info("All mods downloaded successfully.\n") + + # Update UI to show starting self.app.progress_file.after( 0, - lambda: self.app.progress_file.configure(text="All mods downloaded successfully."), + lambda: self.app.progress_file.configure(text=f"Starting download for {self.mod}...") ) - - CTkMessagebox( - title="Download Completed", - width=500, - wraplength=500, - message="Mods successfully downloaded.", - icon="check", - option_1="Ok", + + # Create output directory + os.makedirs(self.output_path, exist_ok=True) + + # Create CoreDownloader with progress callback + core_downloader = CoreDownloader( + output_path=self.output_path, + include_optional=self.include_optional, + logger=self.logger, + config=self.config, + progress_callback=self._handle_progress ) + + # Download the mod and its dependencies + result = core_downloader.download_mod(self.mod_url) + + # Check result and show appropriate message + if result.success: + self.log_info("All mods downloaded successfully.\n") + self.app.progress_file.after( + 0, + lambda: self.app.progress_file.configure(text="All mods downloaded successfully."), + ) + + CTkMessagebox( + title="Download Completed", + width=500, + wraplength=500, + message=f"Successfully downloaded {len(result.downloaded_mods)} mod(s).", + icon="check", + option_1="Ok", + ) + else: + # Some downloads failed + error_msg = f"Downloaded {len(result.downloaded_mods)} mod(s), but {len(result.failed_mods)} failed." + self.log_info(f"{error_msg}\n") + + for mod_name, error in result.failed_mods: + self.log_info(f"Failed: {mod_name} - {error}\n") + + CTkMessagebox( + title="Download Completed with Errors", + width=500, + wraplength=500, + message=error_msg, + icon="warning", + option_1="Ok", + ) except Exception as e: - self.log_info(f"Error: {str(e).split("\n")[0]}\n") + error_msg = str(e).split("\n")[0] + self.log_info(f"Error: {error_msg}\n") CTkMessagebox( title="Error", @@ -112,371 +141,75 @@ def run(self): self.app.download_button.configure(state="normal", text="Start Download") self.app.path_button.configure(state="normal") - def _init_selenium(self) -> Options: - """ - Initialize Selenium WebDriver options. - - Returns: - Chrome Options object - - Raises: - Exception: If chromedriver installation fails + def _handle_progress(self, event_type: str, data): + """Handle progress callbacks from CoreDownloader. + + Args: + event_type: Type of progress event ('analyzing', 'downloading', 'complete', 'error') + data: Event-specific data """ - try: + if event_type == 'analyzing': + # data is mod_name + mod_name = data + self.log_info(f"Analyzing mod {mod_name}...\n") self.app.progress_file.after( 0, - lambda: self.app.progress_file.configure( - text="Downloading and loading dependencies." - ), + lambda: self.app.progress_file.configure(text=f"Analyzing mod {mod_name}") ) - - self.log_info("Downloading application dependencies.\n") - chromedriver_autoinstaller.install() - self.log_info("Finished downloading application dependencies.\n") - - chrome_options = Options() - chrome_options.add_argument("--headless") - chrome_options.add_argument("--window-position=-2400,-2400") - chrome_options.add_argument("--disable-gpu") - - # Find and set a free debugging port - port = find_free_port() - chrome_options.add_argument(f"--remote-debugging-port={port}") - - self.log_info("Configured application dependencies.\n") - return chrome_options - - except Exception as e: - self.log_info(f"Error initializing Selenium: {str(e).split("\n")[0]}\n") - raise - - def init_driver(self) -> webdriver.Chrome: - """ - Initialize a Chrome WebDriver instance. - - Returns: - Chrome WebDriver instance - """ - return webdriver.Chrome(options=self.chrome_options) - - def close_driver(self, driver: webdriver.Chrome): - """ - Close and cleanup a WebDriver instance. - - Args: - driver: WebDriver instance to close - """ - try: - driver.stop_client() - driver.close() - driver.quit() - except Exception as e: - print(f"Error closing driver: {str(e).split("\n")[0]}") - - def get_page_source(self, url: str, is_dependency_check: bool = False) -> BeautifulSoup: - """ - Fetch and parse a webpage. - - Args: - url: URL to fetch - is_dependency_check: Whether to wait for dependency table to load - - Returns: - BeautifulSoup object of the parsed HTML - - Raises: - Exception: If page loading fails - """ - driver = self.init_driver() - - try: - driver.get(url) - - if is_dependency_check: - # Wait for dependency table to load - WebDriverWait(driver, 15).until( - EC.presence_of_element_located((By.CSS_SELECTOR, "table.panel-hole")) - ) - else: - time.sleep(2) - - html = driver.page_source - return BeautifulSoup(html, "html.parser") - except Exception as e: - self.log_info(f"Error loading {url}: {str(e).split("\n")[0]}\n") - return None - finally: - self.close_driver(driver) - - def get_mod_name(self, soup: BeautifulSoup) -> str: - """ - Extract mod name from parsed HTML. - - Args: - soup: BeautifulSoup object of mod page - - Returns: - Mod name - - Raises: - ValueError: If mod name cannot be found - """ - dd_element = soup.find("dd", id="mod-info-name") - if not dd_element: - raise ValueError("Could not find mod name in page") - return dd_element.get_text(strip=True).strip() - - def get_latest_version(self, soup: BeautifulSoup) -> str: - """ - Extract the latest mod version from parsed HTML. - - Args: - soup: BeautifulSoup object of mod page - - Returns: - Latest version identifier - - Raises: - ValueError: If version cannot be found - """ - select = soup.find("select", {"id": "mod-version"}) - if not select: - raise ValueError("No version select element found") - - # Find the latest version (marked with 'last') - for option in select.find_all("option"): - if "(last)" in option.text: - return option["value"] - - # Fallback to first version - first_option = select.find("option") - if not first_option: - raise ValueError("No version options found") - - return first_option["value"] - - def get_required_dependencies(self, mod_name: str) -> List[Tuple[str, str]]: - """ - Fetch required dependencies for a mod. - - Args: - mod_name: Name of the mod - - Returns: - List of (dependency_name, dependency_url) tuples - """ - dependency_url = ( - f"{BASE_FACTORIO_MOD_URL}/{mod_name}/dependencies?direction=out&sort=idx&filter=all" - ) - - try: - soup = self.get_page_source(dependency_url, is_dependency_check=True) - if not soup: - self.log_info(f"Could not fetch dependencies for {mod_name}\n") - return [] - - required_mods = [] - - links = soup.find_all("a", class_="mod-dependencies-required") - for link in links: - dep_name = link.get_text(strip=True) - mod_url = f"{BASE_MOD_URL}{BASE_FACTORIO_MOD_URL}/{dep_name}" - required_mods.append((dep_name, mod_url)) - - if self.include_optional: - for link in soup.find_all("a", class_="mod-dependencies-optional"): - dep_name = link.get_text(strip=True) - mod_url = f"{BASE_MOD_URL}{BASE_FACTORIO_MOD_URL}/{dep_name}" - required_mods.append((dep_name, mod_url)) - - return required_mods - - except Exception as e: - self.log_info(f"Could not fetch dependencies for {mod_name}: {e}\n") - return [] - - def download_file(self, url: str, file_path: str, file_name: str): - """ - Download a file with progress tracking and retry support. - - Args: - url: File URL to download - file_path: Local path to save file - file_name: Display name for the file - """ - entry = self.app.downloader_frame.add_download(file_name) - entry.progress_bar.set(0) - - def _download(): - max_retries = 3 - retry_delay = 2 # seconds - - for attempt in range(1, max_retries + 1): - try: - response = requests.get(url, stream=True, timeout=30) - response.raise_for_status() - - total_size = int(response.headers.get("content-length", 0)) - min_chunk = 64 * 1024 # 64 KB - max_chunk = 4 * 1024 * 1024 # 4 MB - block_size = max(min_chunk, min(total_size // 100, max_chunk)) - progress = 0 - - # Indeterminate progress if no total size - if not total_size: - entry.progress_bar.after( - 0, entry.progress_bar.configure, {"mode": "indeterminate"} - ) - - with open(file_path, "wb") as file: - start_time = time.time() - last_update = start_time - - for chunk in response.iter_content(chunk_size=block_size): - if not chunk: - continue - - file.write(chunk) - progress += len(chunk) - - percentage = progress / total_size if total_size else 0 - now = time.time() - - # Update UI every ~0.2s for smoother visuals - if now - last_update >= 0.2: - elapsed = now - start_time - speed = ( - (progress / 1024 / 1024) / elapsed if elapsed > 0 else 0.0 - ) # MB/s - - downloaded_mb = progress / 1024 / 1024 - total_mb = total_size / 1024 / 1024 if total_size else 0 - - # Thread-safe update using DownloadEntry.update_progress - entry.progress_bar.after( - 0, - lambda p=percentage, d=downloaded_mb, t=total_mb, s=speed: entry.update_progress( - p, d, t, s - ), - ) - - last_update = now - - # ✅ Mark complete - entry.text_label.after(0, entry.mark_complete) - self.log_info(f"Downloaded: {file_path.replace("\\", "/")}.\n") - break # success, exit retry loop - - except Exception as e: - # Delete partial file - if os.path.exists(file_path): - os.remove(file_path) - - if attempt < max_retries: - entry.text_label.after( - 0, lambda x=attempt: entry.mark_retrying(x, max_retries) - ) - self.log_info( - f"Error downloading {file_path} (attempt {attempt}): {e}\nRetrying..." - ) - time.sleep(retry_delay) - else: - entry.text_label.after(0, lambda: entry.mark_failed(str(e))) - self.log_info( - f"Failed to download {file_path} after {max_retries} attempts: {e}\n" - ) - - # Run download in background thread to prevent GUI freeze - t = Thread(target=_download, daemon=True) - t.start() - self.download_threads.append(t) - - def download_mod_with_dependencies(self, mod_url: str, download_path: str): - """ - Recursively download a mod and all its dependencies. - - Args: - mod_url: URL of the mod to download - download_path: Directory to save downloads - """ - # Update UI with current mod being analyzed - mod_name_display = mod_url.split("/")[-1] - self.app.progressbar.stop() - self.app.progress_file.after( - 0, - lambda: self.app.progress_file.configure(text=f"Analyzing mod {mod_name_display}"), - ) - - self.app.progressbar.configure(mode="indeterminate") - self.app.progressbar.start() - - try: - # Fetch mod information - soup = self.get_page_source(mod_url) - mod_name = self.get_mod_name(soup) - latest_version = self.get_latest_version(soup) - - if not mod_name or not latest_version: - self.log_info(f"Error: Could not get mod info for {mod_url}. Skipping!\n") - return - - # Skip reserved dependencies - if mod_name in ("space-age",): - self.log_info( - f"Skipping reserved dependency {mod_name}. Download manually if needed.\n" - ) - return - - self.log_info(f"Loaded mod {mod_name} with version {latest_version}.\n") - self.analyzed_mods.add(mod_url) - - # Construct download URL - download_url = ( - f"{BASE_DOWNLOAD_URL}/{mod_name}/{latest_version}.zip" - f"?anticache={generate_anticache()}" + + elif event_type == 'downloading': + # data is (mod_name, percentage, downloaded_mb, total_mb, speed_mbps) + mod_name, percentage, downloaded_mb, total_mb, speed = data + + # Create download entry if it doesn't exist + if mod_name not in self.active_downloads: + file_name = f"{mod_name}" + entry = self.app.downloader_frame.add_download(file_name) + self.active_downloads[mod_name] = entry + self.log_info(f"Downloading {mod_name}...\n") + + # Update progress + entry = self.active_downloads[mod_name] + entry.progress_bar.after( + 0, + lambda: entry.update_progress(percentage, downloaded_mb, total_mb, speed) ) - file_name = f"{mod_name}_{latest_version}.zip" - file_path = os.path.join(download_path, file_name) - - # Download the mod - os.makedirs(download_path, exist_ok=True) - - if file_name not in self.downloaded_mods: - self.log_info(f"Downloading {file_name}.\n") - self.downloaded_mods.add(file_name) - self.download_file(download_url, file_path, file_name) - else: - self.log_info(f"Mod already downloaded {file_name}. Skipping!\n") - - # Fetch and recursively download dependencies - self.log_info(f"Loading dependencies for {mod_name}.\n") - dependencies = self.get_required_dependencies(mod_name) - - if not dependencies: - self.log_info(f"No dependencies found for {mod_name}.\n") - return - - dep_names = ", ".join([dep_name for dep_name, _ in dependencies]) - self.log_info(f"Dependencies found for {mod_name}: {dep_names}\n") - - for dep_name, dep_url in dependencies: - if dep_name in self.downloaded_mods or dep_url in self.analyzed_mods: - continue - - self.log_info(f"Analyzing dependency {dep_name} of {mod_name}\n") - self.download_mod_with_dependencies(dep_url, download_path) - - except Exception as e: - self.log_info(f"Error processing mod: {str(e).split("\n")[0]}\n") - + + elif event_type == 'complete': + # data is mod_name + mod_name = data + + if mod_name in self.active_downloads: + entry = self.active_downloads[mod_name] + entry.text_label.after(0, entry.mark_complete) + + self.log_info(f"Completed: {mod_name}\n") + + elif event_type == 'error': + # data is (mod_name, error_message) + mod_name, error_message = data + + if mod_name in self.active_downloads: + entry = self.active_downloads[mod_name] + entry.text_label.after(0, lambda: entry.mark_failed(error_message)) + + self.log_info(f"Error downloading {mod_name}: {error_message}\n") + def log_info(self, info: str): """ - Append text to the application's log textbox. + Append text to the application's log textbox and structured logger. Args: info: Text to log """ + # Log to GUI textbox self.app.textbox.configure(state="normal") self.app.textbox.insert("end", info) self.app.textbox.yview("end") self.app.textbox.configure(state="disabled") + + # Log to structured logger + # Remove trailing newline for cleaner log entries + message = info.rstrip('\n') + if message: + self.logger.info(message) diff --git a/src/factorio_mod_downloader/gui/__init__.py b/src/factorio_mod_downloader/gui/__init__.py index d6f118c..8b0527e 100644 --- a/src/factorio_mod_downloader/gui/__init__.py +++ b/src/factorio_mod_downloader/gui/__init__.py @@ -1,3 +1,14 @@ -""" -GUI package initialization -""" +"""GUI module - ensures tkinter is imported for PyInstaller.""" + +# Import tkinter at module level so PyInstaller can detect it +try: + import tkinter + import tkinter.ttk + import tkinter.messagebox + import tkinter.filedialog + import _tkinter + TKINTER_AVAILABLE = True +except ImportError: + TKINTER_AVAILABLE = False + +__all__ = ['TKINTER_AVAILABLE'] \ No newline at end of file diff --git a/src/factorio_mod_downloader/gui/app.py b/src/factorio_mod_downloader/gui/app.py index ee108e0..34ca4de 100644 --- a/src/factorio_mod_downloader/gui/app.py +++ b/src/factorio_mod_downloader/gui/app.py @@ -7,6 +7,8 @@ from factorio_mod_downloader.gui.frames import BodyFrame from factorio_mod_downloader.gui.frames import DownloaderFrame from factorio_mod_downloader.gui.utils import resource_path +from factorio_mod_downloader.infrastructure.config import ConfigManager +from factorio_mod_downloader.infrastructure.logger import LoggerSystem customtkinter.set_appearance_mode("dark") @@ -28,6 +30,12 @@ def __init__(self): except Exception as e: print(f"Warning: Could not load icon: {e}") + # Initialize configuration and logging + self.config_manager = ConfigManager() + self.logger = LoggerSystem.from_config(self.config_manager.config) + + self.logger.info("Factorio Mod Downloader GUI started") + self.grid_columnconfigure((0, 1), weight=1) self.grid_rowconfigure(0, weight=1) @@ -45,3 +53,55 @@ def __init__(self): self.progressbar = self.BodyFrame.progressbar self.download_button = self.BodyFrame.download_button self.textbox = self.BodyFrame.textbox + + # Load GUI preferences from config + self._load_preferences() + + # Register cleanup on window close + self.protocol("WM_DELETE_WINDOW", self._on_closing) + + def _load_preferences(self): + """Load GUI preferences from configuration.""" + try: + # Load default output path if configured + default_path = self.config_manager.get('default_output_path') + if default_path and default_path != './mods': + self.BodyFrame.download_path.delete(0, 'end') + self.BodyFrame.download_path.insert(0, default_path) + + # Load optional dependencies preference + include_optional = self.config_manager.get('include_optional_deps') + self.BodyFrame.optional_deps.set(include_optional) + + self.logger.info("GUI preferences loaded from configuration") + except Exception as e: + self.logger.warning(f"Could not load GUI preferences: {e}") + + def _save_preferences(self): + """Save GUI preferences to configuration.""" + try: + # Save download path if it's been set + download_path = self.BodyFrame.download_path.get().strip() + if download_path: + self.config_manager.set('default_output_path', download_path, self.logger) + + # Save optional dependencies preference + include_optional = self.BodyFrame.optional_deps.get() + self.config_manager.set('include_optional_deps', include_optional, self.logger) + + # Save configuration to file + self.config_manager.save_config() + + self.logger.info("GUI preferences saved to configuration") + except Exception as e: + self.logger.warning(f"Could not save GUI preferences: {e}") + + def _on_closing(self): + """Handle window close event.""" + # Save preferences before closing + self._save_preferences() + + self.logger.info("Factorio Mod Downloader GUI closed") + + # Destroy the window + self.destroy() diff --git a/src/factorio_mod_downloader/gui/frames.py b/src/factorio_mod_downloader/gui/frames.py index e90a08e..dfcf155 100644 --- a/src/factorio_mod_downloader/gui/frames.py +++ b/src/factorio_mod_downloader/gui/frames.py @@ -394,7 +394,17 @@ def _download_button_action(self): # Import here to avoid circular imports from factorio_mod_downloader.downloader.mod_downloader import ModDownloader - mod_downloader = ModDownloader(mod_url, download_path, self) + # Get config and logger from the main app + config = None + logger = None + + # Try to get config and logger from master (the main App) + if hasattr(self.master, 'config_manager'): + config = self.master.config_manager.config + if hasattr(self.master, 'logger'): + logger = self.master.logger + + mod_downloader = ModDownloader(mod_url, download_path, self, logger=logger, config=config) mod_downloader.start() except Exception as e: diff --git a/src/factorio_mod_downloader/infrastructure/__init__.py b/src/factorio_mod_downloader/infrastructure/__init__.py new file mode 100644 index 0000000..252fa47 --- /dev/null +++ b/src/factorio_mod_downloader/infrastructure/__init__.py @@ -0,0 +1,33 @@ +"""Infrastructure module for Factorio Mod Downloader.""" + +from factorio_mod_downloader.infrastructure.config import Config, ConfigManager +from factorio_mod_downloader.infrastructure.errors import ( + ErrorCategory, + ModDownloaderError, + NetworkError, + ValidationError, + FileSystemError, + ParsingError, + is_retryable_error, + get_error_suggestion, + categorize_error +) +from factorio_mod_downloader.infrastructure.registry import ModEntry, ModRegistry +from factorio_mod_downloader.infrastructure.recovery import RecoveryManager + +__all__ = [ + 'Config', + 'ConfigManager', + 'ErrorCategory', + 'ModDownloaderError', + 'NetworkError', + 'ValidationError', + 'FileSystemError', + 'ParsingError', + 'is_retryable_error', + 'get_error_suggestion', + 'categorize_error', + 'ModEntry', + 'ModRegistry', + 'RecoveryManager' +] diff --git a/src/factorio_mod_downloader/infrastructure/config.py b/src/factorio_mod_downloader/infrastructure/config.py new file mode 100644 index 0000000..5a0467a --- /dev/null +++ b/src/factorio_mod_downloader/infrastructure/config.py @@ -0,0 +1,230 @@ +"""Configuration management for Factorio Mod Downloader.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional +import os +import yaml + + +def get_default_factorio_mods_path() -> str: + """Get the default Factorio mods directory path. + + Returns: + Path to Factorio mods directory. + """ + # Try to find Factorio mods directory + if os.name == 'nt': # Windows + appdata = os.getenv('APPDATA') + if appdata: + factorio_mods = Path(appdata) / 'Factorio' / 'mods' + return str(factorio_mods) + else: # Linux/Mac + home = Path.home() + # Linux + factorio_mods = home / '.factorio' / 'mods' + return str(factorio_mods) + # Note: Mac uses same path as Linux for Factorio + + # Should not reach here, but return a sensible default + return str(Path.home() / 'factorio' / 'mods') + + +@dataclass +class Config: + """Configuration data class with validation.""" + + default_output_path: str = field(default_factory=get_default_factorio_mods_path) + include_optional_deps: bool = False + max_retries: int = 3 + retry_delay: int = 2 + log_level: str = 'INFO' + concurrent_downloads: int = 3 + quiet: bool = False + verbose: bool = False + json_output: bool = False + + def __post_init__(self): + """Validate configuration values after initialization.""" + self._validate() + + def _validate(self): + """Validate configuration values.""" + # Validate max_retries + if not isinstance(self.max_retries, int) or self.max_retries < 0: + raise ValueError(f"max_retries must be a non-negative integer, got {self.max_retries}") + + # Validate retry_delay + if not isinstance(self.retry_delay, int) or self.retry_delay < 0: + raise ValueError(f"retry_delay must be a non-negative integer, got {self.retry_delay}") + + # Validate log_level + valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + if self.log_level.upper() not in valid_log_levels: + raise ValueError( + f"log_level must be one of {valid_log_levels}, got {self.log_level}" + ) + self.log_level = self.log_level.upper() + + # Validate concurrent_downloads + if not isinstance(self.concurrent_downloads, int) or self.concurrent_downloads < 1: + raise ValueError( + f"concurrent_downloads must be a positive integer, got {self.concurrent_downloads}" + ) + + # Validate boolean fields + if not isinstance(self.include_optional_deps, bool): + raise ValueError( + f"include_optional_deps must be a boolean, got {self.include_optional_deps}" + ) + if not isinstance(self.quiet, bool): + raise ValueError(f"quiet must be a boolean, got {self.quiet}") + if not isinstance(self.verbose, bool): + raise ValueError(f"verbose must be a boolean, got {self.verbose}") + if not isinstance(self.json_output, bool): + raise ValueError(f"json_output must be a boolean, got {self.json_output}") + + # Validate default_output_path is a string + if not isinstance(self.default_output_path, str): + raise ValueError( + f"default_output_path must be a string, got {self.default_output_path}" + ) + + def to_dict(self) -> dict: + """Convert config to dictionary for serialization.""" + return { + 'default_output_path': self.default_output_path, + 'include_optional_deps': self.include_optional_deps, + 'max_retries': self.max_retries, + 'retry_delay': self.retry_delay, + 'log_level': self.log_level, + 'concurrent_downloads': self.concurrent_downloads, + } + + @classmethod + def from_dict(cls, data: dict) -> 'Config': + """Create Config from dictionary.""" + # Only use keys that are in the Config dataclass + valid_keys = { + 'default_output_path', 'include_optional_deps', 'max_retries', + 'retry_delay', 'log_level', 'concurrent_downloads', 'quiet', + 'verbose', 'json_output' + } + filtered_data = {k: v for k, v in data.items() if k in valid_keys} + return cls(**filtered_data) + + +class ConfigManager: + """Manages application configuration.""" + + DEFAULT_CONFIG_PATH = Path.home() / '.factorio-mod-downloader' / 'config.yaml' + + def __init__(self, config_path: Optional[Path] = None): + """Initialize ConfigManager. + + Args: + config_path: Path to configuration file. If None, uses default path. + """ + self.config_path = config_path or self.DEFAULT_CONFIG_PATH + self.config = self._load_config() + + def _load_config(self) -> Config: + """Load configuration from file or create default. + + Returns: + Config object with loaded or default values. + """ + if not self.config_path.exists(): + # Return default config if file doesn't exist + return Config() + + try: + with open(self.config_path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + if data is None: + return Config() + return Config.from_dict(data) + except Exception as e: + # If there's any error loading, return default config + print(f"Warning: Could not load config from {self.config_path}: {e}") + print("Using default configuration.") + return Config() + + def save_config(self): + """Save current configuration to file.""" + # Ensure directory exists + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + # Save config to YAML file + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump( + self.config.to_dict(), + f, + default_flow_style=False, + sort_keys=False + ) + + def get(self, key: str) -> Any: + """Get configuration value. + + Args: + key: Configuration key to retrieve. + + Returns: + Configuration value. + + Raises: + AttributeError: If key doesn't exist. + """ + if not hasattr(self.config, key): + raise AttributeError(f"Configuration key '{key}' does not exist") + return getattr(self.config, key) + + def set(self, key: str, value: Any, logger=None): + """Set configuration value. + + Args: + key: Configuration key to set. + value: Value to set. + logger: Optional LoggerSystem instance for logging changes. + + Raises: + AttributeError: If key doesn't exist. + ValueError: If value is invalid for the key. + """ + if not hasattr(self.config, key): + raise AttributeError(f"Configuration key '{key}' does not exist") + + # Store old value for logging + old_value = getattr(self.config, key) + + # Create a new config with the updated value to trigger validation + config_dict = self.config.to_dict() + config_dict[key] = value + + # This will validate the new value + self.config = Config.from_dict(config_dict) + + # Log configuration change if logger provided + if logger: + logger.info( + f"Configuration changed", + key=key, + old_value=old_value, + new_value=value + ) + + def init_config(self): + """Initialize default configuration file.""" + # Create default config + self.config = Config() + # Save to file + self.save_config() + + def list_all(self) -> dict: + """List all configuration values. + + Returns: + Dictionary of all configuration values. + """ + return self.config.to_dict() diff --git a/src/factorio_mod_downloader/infrastructure/errors.py b/src/factorio_mod_downloader/infrastructure/errors.py new file mode 100644 index 0000000..f716c8a --- /dev/null +++ b/src/factorio_mod_downloader/infrastructure/errors.py @@ -0,0 +1,219 @@ +"""Error categories and handling for Factorio Mod Downloader. + +This module defines error types and provides utilities for error handling, +including retry logic for retryable errors and fail-fast for non-retryable errors. +""" + +from enum import Enum +from typing import Optional + + +class ErrorCategory(Enum): + """Categories of errors that can occur during mod downloading.""" + NETWORK = "network" + VALIDATION = "validation" + FILESYSTEM = "filesystem" + PARSING = "parsing" + + +class ModDownloaderError(Exception): + """Base exception for all mod downloader errors. + + Attributes: + message: Human-readable error message + category: Error category for handling logic + retryable: Whether this error can be retried + suggestion: Optional suggestion for fixing the error + """ + + def __init__( + self, + message: str, + category: ErrorCategory, + retryable: bool = False, + suggestion: Optional[str] = None + ): + """Initialize ModDownloaderError. + + Args: + message: Human-readable error message + category: Error category + retryable: Whether this error can be retried + suggestion: Optional suggestion for fixing the error + """ + super().__init__(message) + self.message = message + self.category = category + self.retryable = retryable + self.suggestion = suggestion + + def __str__(self): + """Return string representation of error.""" + return self.message + + +class NetworkError(ModDownloaderError): + """Network-related errors (connection failures, timeouts). + + These errors are typically retryable with exponential backoff. + """ + + def __init__(self, message: str, suggestion: Optional[str] = None): + """Initialize NetworkError. + + Args: + message: Human-readable error message + suggestion: Optional suggestion for fixing the error + """ + super().__init__( + message=message, + category=ErrorCategory.NETWORK, + retryable=True, + suggestion=suggestion or "Check your internet connection and try again" + ) + + +class ValidationError(ModDownloaderError): + """Validation errors (invalid URLs, malformed input). + + These errors are not retryable and should fail fast. + """ + + def __init__(self, message: str, suggestion: Optional[str] = None): + """Initialize ValidationError. + + Args: + message: Human-readable error message + suggestion: Optional suggestion for fixing the error + """ + super().__init__( + message=message, + category=ErrorCategory.VALIDATION, + retryable=False, + suggestion=suggestion + ) + + +class FileSystemError(ModDownloaderError): + """File system errors (permission denied, disk full). + + These errors are not retryable and should fail fast. + """ + + def __init__(self, message: str, suggestion: Optional[str] = None): + """Initialize FileSystemError. + + Args: + message: Human-readable error message + suggestion: Optional suggestion for fixing the error + """ + super().__init__( + message=message, + category=ErrorCategory.FILESYSTEM, + retryable=False, + suggestion=suggestion + ) + + +class ParsingError(ModDownloaderError): + """Parsing errors (cannot extract mod info from HTML). + + These errors are retryable once after a delay, as the page might be temporarily unavailable. + """ + + def __init__(self, message: str, suggestion: Optional[str] = None): + """Initialize ParsingError. + + Args: + message: Human-readable error message + suggestion: Optional suggestion for fixing the error + """ + super().__init__( + message=message, + category=ErrorCategory.PARSING, + retryable=True, + suggestion=suggestion or "The mod page might be temporarily unavailable. Try again later." + ) + + +def is_retryable_error(error: Exception) -> bool: + """Check if an error is retryable. + + Args: + error: Exception to check + + Returns: + True if the error is retryable, False otherwise + """ + if isinstance(error, ModDownloaderError): + return error.retryable + + # Check for common retryable exceptions + import requests + if isinstance(error, ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + TimeoutError, + ConnectionError + )): + return True + + return False + + +def get_error_suggestion(error: Exception) -> Optional[str]: + """Get a suggestion for fixing an error. + + Args: + error: Exception to get suggestion for + + Returns: + Suggestion string if available, None otherwise + """ + if isinstance(error, ModDownloaderError): + return error.suggestion + + # Provide suggestions for common exceptions + import requests + if isinstance(error, requests.exceptions.ConnectionError): + return "Check your internet connection and try again" + elif isinstance(error, requests.exceptions.Timeout): + return "The server is taking too long to respond. Try again later." + elif isinstance(error, PermissionError): + return "Check file permissions and ensure you have write access to the output directory" + elif isinstance(error, OSError) and "No space left on device" in str(error): + return "Free up disk space and try again" + + return None + + +def categorize_error(error: Exception) -> ErrorCategory: + """Categorize an exception into an error category. + + Args: + error: Exception to categorize + + Returns: + ErrorCategory for the exception + """ + if isinstance(error, ModDownloaderError): + return error.category + + # Categorize common exceptions + import requests + if isinstance(error, ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + TimeoutError, + ConnectionError + )): + return ErrorCategory.NETWORK + elif isinstance(error, (PermissionError, OSError, IOError)): + return ErrorCategory.FILESYSTEM + elif isinstance(error, (ValueError, TypeError)): + return ErrorCategory.VALIDATION + + # Default to parsing for unknown errors + return ErrorCategory.PARSING diff --git a/src/factorio_mod_downloader/infrastructure/logger.py b/src/factorio_mod_downloader/infrastructure/logger.py new file mode 100644 index 0000000..89c0d47 --- /dev/null +++ b/src/factorio_mod_downloader/infrastructure/logger.py @@ -0,0 +1,189 @@ +"""Structured logging system for Factorio Mod Downloader.""" + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Optional + + +class LoggerSystem: + """Structured logging system with file rotation and console output. + + Supports: + - File logging with rotation (10MB max, 5 backups) + - Console logging with configurable levels + - DEBUG, INFO, WARNING, ERROR, CRITICAL levels + - --quiet mode (only errors to console) + - --verbose mode (debug output to console) + """ + + LOG_DIR = Path.home() / '.factorio-mod-downloader' / 'logs' + LOG_FILE = 'factorio-mod-downloader.log' + MAX_BYTES = 10 * 1024 * 1024 # 10 MB + BACKUP_COUNT = 5 + + def __init__(self, log_level: str = 'INFO', console_level: Optional[str] = None, + quiet: bool = False, verbose: bool = False): + """Initialize the logging system. + + Args: + log_level: Log level for file output (DEBUG, INFO, WARNING, ERROR, CRITICAL) + console_level: Log level for console output. If None, uses log_level. + quiet: If True, suppress all console output except errors + verbose: If True, enable DEBUG level console output + """ + self.log_level = log_level.upper() + + # Determine console level based on flags + if quiet: + self.console_level = 'ERROR' + elif verbose: + self.console_level = 'DEBUG' + elif console_level: + self.console_level = console_level.upper() + else: + self.console_level = log_level.upper() + + self.logger = self._setup_logger() + + def _setup_logger(self) -> logging.Logger: + """Set up logger with file and console handlers. + + Returns: + Configured logger instance. + """ + # Create logger + logger = logging.getLogger('factorio_mod_downloader') + logger.setLevel(logging.DEBUG) # Capture all levels, handlers will filter + + # Remove existing handlers to avoid duplicates + logger.handlers.clear() + + # Create log directory if it doesn't exist + self.LOG_DIR.mkdir(parents=True, exist_ok=True) + + # File handler with rotation + log_file_path = self.LOG_DIR / self.LOG_FILE + file_handler = RotatingFileHandler( + log_file_path, + maxBytes=self.MAX_BYTES, + backupCount=self.BACKUP_COUNT, + encoding='utf-8' + ) + file_handler.setLevel(getattr(logging, self.log_level)) + + # File formatter with detailed information + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(file_formatter) + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(getattr(logging, self.console_level)) + + # Console formatter (simpler than file) + console_formatter = logging.Formatter( + '%(levelname)s: %(message)s' + ) + console_handler.setFormatter(console_formatter) + + # Add handlers to logger + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + + def debug(self, message: str, **kwargs): + """Log debug message. + + Args: + message: Log message + **kwargs: Additional context to include in log + """ + if kwargs: + message = f"{message} - {kwargs}" + self.logger.debug(message) + + def info(self, message: str, **kwargs): + """Log info message. + + Args: + message: Log message + **kwargs: Additional context to include in log + """ + if kwargs: + message = f"{message} - {kwargs}" + self.logger.info(message) + + def warning(self, message: str, **kwargs): + """Log warning message. + + Args: + message: Log message + **kwargs: Additional context to include in log + """ + if kwargs: + message = f"{message} - {kwargs}" + self.logger.warning(message) + + def error(self, message: str, **kwargs): + """Log error message. + + Args: + message: Log message + **kwargs: Additional context to include in log + """ + if kwargs: + message = f"{message} - {kwargs}" + self.logger.error(message) + + def critical(self, message: str, **kwargs): + """Log critical message. + + Args: + message: Log message + **kwargs: Additional context to include in log + """ + if kwargs: + message = f"{message} - {kwargs}" + self.logger.critical(message) + + def set_console_level(self, level: str): + """Change console log level dynamically. + + Args: + level: New log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + """ + level = level.upper() + self.console_level = level + + # Update console handler level + for handler in self.logger.handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, RotatingFileHandler): + handler.setLevel(getattr(logging, level)) + + def set_quiet(self): + """Suppress all console output except errors.""" + self.set_console_level('ERROR') + + def set_verbose(self): + """Enable verbose console output (DEBUG level).""" + self.set_console_level('DEBUG') + + @classmethod + def from_config(cls, config) -> 'LoggerSystem': + """Create LoggerSystem from Config object. + + Args: + config: Config object with log_level, quiet, and verbose settings + + Returns: + Configured LoggerSystem instance + """ + return cls( + log_level=config.log_level, + quiet=config.quiet, + verbose=config.verbose + ) diff --git a/src/factorio_mod_downloader/infrastructure/recovery.py b/src/factorio_mod_downloader/infrastructure/recovery.py new file mode 100644 index 0000000..a5be52a --- /dev/null +++ b/src/factorio_mod_downloader/infrastructure/recovery.py @@ -0,0 +1,284 @@ +"""Download recovery and resumption management for Factorio Mod Downloader.""" + +import os +from pathlib import Path +from typing import Optional +import requests + + +class RecoveryManager: + """Manages download recovery and resumption. + + Handles: + - Partial file detection (.part files) + - Resume position calculation + - Partial file validation + - Server support detection for range requests + - Cleanup of completed downloads + """ + + PART_EXTENSION = '.part' + + def __init__(self, logger, config): + """Initialize RecoveryManager. + + Args: + logger: LoggerSystem instance for logging + config: Config object with retry settings + """ + self.logger = logger + self.config = config + + def _get_partial_path(self, file_path: str) -> str: + """Get the .part file path for a given file path. + + Args: + file_path: Target file path + + Returns: + Path to the .part file + """ + return f"{file_path}{self.PART_EXTENSION}" + + def _partial_file_exists(self, file_path: str) -> bool: + """Check if a partial file exists. + + Args: + file_path: Target file path + + Returns: + True if .part file exists, False otherwise + """ + partial_path = self._get_partial_path(file_path) + return os.path.exists(partial_path) + + def get_resume_position(self, file_path: str) -> int: + """Get byte position to resume from. + + Args: + file_path: Target file path + + Returns: + Byte position to resume from (0 if no partial file or invalid) + """ + partial_path = self._get_partial_path(file_path) + + if not os.path.exists(partial_path): + self.logger.debug(f"No partial file found at {partial_path}") + return 0 + + try: + file_size = os.path.getsize(partial_path) + if file_size > 0: + self.logger.info( + f"Found partial download", + file=partial_path, + size_bytes=file_size + ) + return file_size + else: + self.logger.warning(f"Partial file is empty: {partial_path}") + return 0 + except OSError as e: + self.logger.error(f"Error reading partial file size: {e}") + return 0 + + def validate_partial(self, file_path: str) -> bool: + """Validate partial file integrity. + + Checks if the partial file exists and has a valid size. + + Args: + file_path: Target file path + + Returns: + True if partial file is valid, False otherwise + """ + partial_path = self._get_partial_path(file_path) + + if not os.path.exists(partial_path): + self.logger.debug(f"Partial file does not exist: {partial_path}") + return False + + try: + file_size = os.path.getsize(partial_path) + + # Check if file has content + if file_size <= 0: + self.logger.warning(f"Partial file is empty or invalid: {partial_path}") + return False + + # Check if file is readable + with open(partial_path, 'rb') as f: + # Try to read first byte to ensure file is accessible + f.read(1) + + self.logger.debug( + f"Partial file validation passed", + file=partial_path, + size_bytes=file_size + ) + return True + + except (OSError, IOError) as e: + self.logger.error( + f"Partial file validation failed", + file=partial_path, + error=str(e) + ) + return False + + def can_resume(self, file_path: str, url: str) -> bool: + """Check if a download can be resumed. + + Checks both if a valid partial file exists and if the server + supports range requests (HTTP Range header). + + Args: + file_path: Target file path + url: Download URL to check for range support + + Returns: + True if download can be resumed, False otherwise + """ + # First check if we have a valid partial file + if not self.validate_partial(file_path): + self.logger.debug(f"Cannot resume: no valid partial file for {file_path}") + return False + + # Check if server supports range requests + try: + self.logger.debug(f"Checking server range support for {url}") + response = requests.head(url, timeout=10, allow_redirects=True) + + # Check for Accept-Ranges header + accept_ranges = response.headers.get('Accept-Ranges', '').lower() + + if accept_ranges == 'bytes': + self.logger.info(f"Server supports range requests for {url}") + return True + elif accept_ranges == 'none': + self.logger.warning( + f"Server explicitly does not support range requests", + url=url + ) + return False + else: + # Some servers don't send Accept-Ranges but still support it + # Try a range request to verify + self.logger.debug(f"Accept-Ranges header not found, testing range request") + test_response = requests.head( + url, + headers={'Range': 'bytes=0-0'}, + timeout=10, + allow_redirects=True + ) + + if test_response.status_code == 206: # Partial Content + self.logger.info(f"Server supports range requests (verified by test)") + return True + else: + self.logger.warning( + f"Server does not support range requests", + url=url, + status_code=test_response.status_code + ) + return False + + except requests.RequestException as e: + self.logger.error( + f"Error checking server range support", + url=url, + error=str(e) + ) + return False + + def create_partial_file(self, file_path: str) -> str: + """Create .part file for partial download. + + Args: + file_path: Target file path + + Returns: + Path to the created .part file + """ + partial_path = self._get_partial_path(file_path) + + # Ensure parent directory exists + parent_dir = os.path.dirname(partial_path) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + # Create empty .part file if it doesn't exist + if not os.path.exists(partial_path): + with open(partial_path, 'wb') as f: + pass # Create empty file + self.logger.debug(f"Created partial file: {partial_path}") + else: + self.logger.debug(f"Partial file already exists: {partial_path}") + + return partial_path + + def finalize_download(self, partial_path: str, final_path: str): + """Move partial file to final location. + + Renames the .part file to the final filename after successful download. + + Args: + partial_path: Path to the .part file + final_path: Final destination path + + Raises: + OSError: If file move operation fails + """ + try: + # Ensure parent directory exists for final path + parent_dir = os.path.dirname(final_path) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + # Remove final file if it already exists + if os.path.exists(final_path): + self.logger.warning(f"Overwriting existing file: {final_path}") + os.remove(final_path) + + # Move partial file to final location + os.rename(partial_path, final_path) + + self.logger.info( + f"Download finalized", + partial_file=partial_path, + final_file=final_path + ) + + except OSError as e: + self.logger.error( + f"Error finalizing download", + partial_file=partial_path, + final_file=final_path, + error=str(e) + ) + raise + + def cleanup_partial(self, file_path: str): + """Clean up partial download files. + + Removes the .part file after successful download completion. + + Args: + file_path: Target file path (not the .part file) + """ + partial_path = self._get_partial_path(file_path) + + if os.path.exists(partial_path): + try: + os.remove(partial_path) + self.logger.debug(f"Cleaned up partial file: {partial_path}") + except OSError as e: + self.logger.warning( + f"Could not clean up partial file", + file=partial_path, + error=str(e) + ) + else: + self.logger.debug(f"No partial file to clean up: {partial_path}") diff --git a/src/factorio_mod_downloader/infrastructure/registry.py b/src/factorio_mod_downloader/infrastructure/registry.py new file mode 100644 index 0000000..ad2c753 --- /dev/null +++ b/src/factorio_mod_downloader/infrastructure/registry.py @@ -0,0 +1,237 @@ +"""Mod registry for tracking downloaded mods and their versions.""" + +import json +from dataclasses import dataclass, asdict +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional +import re + + +@dataclass +class ModEntry: + """Registry entry for a mod. + + Attributes: + name: Mod name + version: Mod version + file_path: Path to the mod file + download_date: Date when the mod was downloaded + size: File size in bytes + """ + + name: str + version: str + file_path: str + download_date: str # ISO format datetime string + size: int + + def to_dict(self) -> dict: + """Convert ModEntry to dictionary for serialization.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> 'ModEntry': + """Create ModEntry from dictionary.""" + return cls(**data) + + +class ModRegistry: + """Registry of downloaded mods with JSON persistence. + + Maintains a local database of downloaded mods at + ~/.factorio-mod-downloader/mod_registry.json + """ + + REGISTRY_PATH = Path.home() / '.factorio-mod-downloader' / 'mod_registry.json' + + def __init__(self, registry_path: Optional[Path] = None): + """Initialize ModRegistry. + + Args: + registry_path: Path to registry file. If None, uses default path. + """ + self.registry_path = registry_path or self.REGISTRY_PATH + self.registry: Dict[str, ModEntry] = self._load_registry() + + def _load_registry(self) -> Dict[str, ModEntry]: + """Load registry from file. + + Returns: + Dictionary mapping mod names to ModEntry objects. + """ + if not self.registry_path.exists(): + return {} + + try: + with open(self.registry_path, 'r', encoding='utf-8') as f: + data = json.load(f) + return { + name: ModEntry.from_dict(entry_data) + for name, entry_data in data.items() + } + except Exception as e: + print(f"Warning: Could not load registry from {self.registry_path}: {e}") + print("Starting with empty registry.") + return {} + + def save_registry(self): + """Save registry to file.""" + # Ensure directory exists + self.registry_path.parent.mkdir(parents=True, exist_ok=True) + + # Convert registry to serializable format + data = { + name: entry.to_dict() + for name, entry in self.registry.items() + } + + # Save to JSON file + with open(self.registry_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def add_mod(self, mod_name: str, version: str, file_path: str, size: int = 0): + """Add or update a mod in the registry. + + Args: + mod_name: Name of the mod + version: Version of the mod + file_path: Path to the mod file + size: File size in bytes (default: 0, will be calculated if file exists) + """ + # Calculate size if not provided and file exists + if size == 0: + path = Path(file_path) + if path.exists(): + size = path.stat().st_size + + # Create mod entry + entry = ModEntry( + name=mod_name, + version=version, + file_path=file_path, + download_date=datetime.now().isoformat(), + size=size + ) + + # Add to registry (overwrites if exists) + self.registry[mod_name] = entry + + def get_mod(self, mod_name: str) -> Optional[ModEntry]: + """Get mod information from registry. + + Args: + mod_name: Name of the mod to retrieve + + Returns: + ModEntry if found, None otherwise + """ + return self.registry.get(mod_name) + + def list_mods(self) -> List[ModEntry]: + """List all registered mods. + + Returns: + List of all ModEntry objects in the registry + """ + return list(self.registry.values()) + + def scan_directory(self, directory: Path) -> List[ModEntry]: + """Scan directory for mod files and update registry. + + Parses mod filenames to extract name and version. + Expected format: modname_version.zip + + Args: + directory: Directory to scan for mod files + + Returns: + List of ModEntry objects for mods found in directory + """ + if not directory.exists(): + raise FileNotFoundError(f"Directory not found: {directory}") + + if not directory.is_dir(): + raise NotADirectoryError(f"Not a directory: {directory}") + + found_mods = [] + + # Find all .zip files in directory + for file_path in directory.glob('*.zip'): + # Parse filename to extract mod name and version + mod_info = self._parse_mod_filename(file_path.name) + + if mod_info: + mod_name, version = mod_info + size = file_path.stat().st_size + + # Add to registry + self.add_mod( + mod_name=mod_name, + version=version, + file_path=str(file_path), + size=size + ) + + # Add to found mods list + found_mods.append(self.registry[mod_name]) + + # Save updated registry + if found_mods: + self.save_registry() + + return found_mods + + def _parse_mod_filename(self, filename: str) -> Optional[tuple[str, str]]: + """Parse mod filename to extract name and version. + + Expected format: modname_version.zip + Examples: + - Krastorio2_1.3.24.zip -> ("Krastorio2", "1.3.24") + - space-exploration_0.6.138.zip -> ("space-exploration", "0.6.138") + + Args: + filename: Mod filename to parse + + Returns: + Tuple of (mod_name, version) if successful, None otherwise + """ + # Remove .zip extension + if not filename.endswith('.zip'): + return None + + name_without_ext = filename[:-4] + + # Pattern: modname_version + # Version typically contains numbers and dots, may have letters + # Split on last underscore to handle mod names with underscores + parts = name_without_ext.rsplit('_', 1) + + if len(parts) != 2: + return None + + mod_name, version = parts + + # Validate version format (should contain at least one digit) + if not re.search(r'\d', version): + return None + + return (mod_name, version) + + def remove_mod(self, mod_name: str) -> bool: + """Remove a mod from the registry. + + Args: + mod_name: Name of the mod to remove + + Returns: + True if mod was removed, False if not found + """ + if mod_name in self.registry: + del self.registry[mod_name] + return True + return False + + def clear(self): + """Clear all entries from the registry.""" + self.registry.clear() diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7cbd4a9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,620 @@ +use pyo3::prelude::*; +use reqwest; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::pin::Pin; +use std::future::Future; + +mod shared; +mod main_download; +mod batch_download; + +use main_download::download_mod_with_deps_enhanced_py; +use batch_download::{batch_download_mods_enhanced_py, parse_batch_file_py}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct ModInfo { + name: String, + title: String, + summary: String, + downloads_count: u64, + owner: String, + releases: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct Release { + version: String, + download_url: String, + file_name: String, + released_at: String, + sha1: String, + info_json: InfoJson, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct InfoJson { + factorio_version: String, + dependencies: Vec, +} + +#[derive(Debug, Clone)] +struct Dependency { + name: String, + optional: bool, +} + +#[derive(Debug, Clone)] +struct DownloadPlan { + mod_name: String, + version: String, + file_name: String, +} + +struct Config { + target_factorio_version: String, + target_mod_version: Option, + install_optional_deps: bool, + install_optional_all_deps: bool, + max_depth: usize, +} + +#[pyclass] +#[derive(Clone)] +pub struct DownloadResult { + #[pyo3(get)] + pub success: bool, + #[pyo3(get)] + pub downloaded_mods: Vec, + #[pyo3(get)] + pub failed_mods: Vec<(String, String)>, + #[pyo3(get)] + pub total_size: u64, + #[pyo3(get)] + pub duration: f64, +} + +#[pymethods] +impl DownloadResult { + fn __repr__(&self) -> String { + format!( + "DownloadResult(success={}, downloaded={}, failed={}, size={} MB, duration={:.2}s)", + self.success, + self.downloaded_mods.len(), + self.failed_mods.len(), + self.total_size / 1024 / 1024, + self.duration + ) + } +} + +fn resolve_dependencies<'a>( + mod_id: &'a str, + config: &'a Config, + visited: &'a mut HashSet, + depth: usize, + parent_is_optional: bool, +) -> Pin, Box>> + 'a>> { + Box::pin(async move { + if depth > config.max_depth { + return Ok(vec![]); + } + + if visited.contains(mod_id) { + return Ok(vec![]); + } + visited.insert(mod_id.to_string()); + + if is_base_dependency(mod_id) { + return Ok(vec![]); + } + + let mod_info = match get_mod_info(mod_id).await { + Ok(info) => info, + Err(_) => return Ok(vec![]), + }; + + let release = match find_compatible_release(&mod_info, config, depth == 0) { + Ok(r) => r, + Err(_) => return Ok(vec![]), + }; + + let dependencies = parse_dependencies(&release.info_json.dependencies); + + let mut plan = vec![DownloadPlan { + mod_name: mod_info.name.clone(), + version: release.version.clone(), + file_name: release.file_name.clone(), + }]; + + for dep in dependencies { + if is_base_dependency(&dep.name) { + continue; + } + + let should_install = if dep.optional { + if depth == 0 { + config.install_optional_deps + } else if parent_is_optional { + config.install_optional_all_deps + } else { + config.install_optional_all_deps + } + } else { + true + }; + + if !should_install { + continue; + } + + match resolve_dependencies( + &dep.name, + config, + visited, + depth + 1, + dep.optional || parent_is_optional, + ).await { + Ok(mut dep_plans) => { + plan.append(&mut dep_plans); + } + Err(_) => {} + } + } + + Ok(plan) + }) +} + +fn find_compatible_release( + mod_info: &ModInfo, + config: &Config, + is_main_mod: bool, +) -> Result> { + if is_main_mod { + if let Some(target_version) = &config.target_mod_version { + if let Some(release) = mod_info.releases.iter().find(|r| &r.version == target_version) { + return Ok(release.clone()); + } + return Err(format!("Version {} not found", target_version).into()); + } + } + + let compatible: Vec<&Release> = mod_info.releases + .iter() + .filter(|r| is_factorio_version_compatible( + &r.info_json.factorio_version, + &config.target_factorio_version + )) + .collect(); + + if let Some(latest) = compatible.last() { + Ok((*latest).clone()) + } else { + mod_info.releases + .last() + .cloned() + .ok_or_else(|| "No releases found".into()) + } +} + +fn parse_dependencies(deps: &[String]) -> Vec { + let mut result = Vec::new(); + + for dep_str in deps { + let dep_str = dep_str.trim(); + + if dep_str.is_empty() { + continue; + } + + let mut optional = false; + let mut incompatible = false; + let mut dep_name = dep_str; + + if dep_name.starts_with('?') { + optional = true; + dep_name = &dep_name[1..].trim(); + } + if dep_name.starts_with('!') { + incompatible = true; + dep_name = &dep_name[1..].trim(); + } + if dep_name.starts_with('~') { + dep_name = &dep_name[1..].trim(); + } + if dep_name.starts_with("(?)") { + optional = true; + dep_name = &dep_name[3..].trim(); + } + + if incompatible { + continue; + } + + let parts: Vec<&str> = dep_name.split_whitespace().collect(); + let name = parts[0].to_string(); + + result.push(Dependency { + name, + optional, + }); + } + + result +} + +fn is_base_dependency(name: &str) -> bool { + matches!( + name, + "base" | "core" | "space-age" | "elevated-rails" | "quality" + ) +} + +fn is_factorio_version_compatible(release_version: &str, target_version: &str) -> bool { + let release_parts: Vec<&str> = release_version.split('.').collect(); + let target_parts: Vec<&str> = target_version.split('.').collect(); + + if release_parts.is_empty() || target_parts.is_empty() { + return false; + } + + if release_parts[0] != target_parts[0] { + return false; + } + + if target_parts.len() == 1 { + return true; + } + + release_parts.len() >= 2 && release_parts[1] == target_parts[1] +} + +async fn get_mod_info(mod_id: &str) -> Result> { + let api_url = format!("https://re146.dev/factorio/mods/modinfo?id={}", mod_id); + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let response = client.get(&api_url).send().await?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status()).into()); + } + + let mod_info: ModInfo = response.json().await?; + Ok(mod_info) +} + +async fn download_mod( + mod_name: &str, + version: &str, + file_name: &str, + output_path: &str, +) -> Result> { + let download_url = format!("https://mods-storage.re146.dev/{}/{}.zip", mod_name, version); + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0") + .timeout(std::time::Duration::from_secs(120)) + .build()?; + + let response = client.get(&download_url).send().await?; + + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status()).into()); + } + + let bytes = response.bytes().await?; + let size = bytes.len() as u64; + + let file_path = Path::new(output_path).join(file_name); + let mut file = File::create(file_path)?; + file.write_all(&bytes)?; + + Ok(size) +} + +fn extract_mod_id(url: &str) -> Result> { + let url = url.trim_end_matches('/'); + + if let Some(mod_part) = url.split("/mod/").nth(1) { + let mod_id = mod_part.split('?').next().unwrap_or(mod_part); + if mod_id.is_empty() { + return Err("Mod ID is empty".into()); + } + Ok(mod_id.to_string()) + } else { + Err("Invalid URL".into()) + } +} + +/// Download a single mod with dependencies +#[pyfunction] +#[pyo3(signature = (mod_url, output_path, factorio_version="2.0", include_optional=true, include_optional_all=false, target_mod_version=None, max_depth=10))] +fn download_mod_with_deps( + mod_url: String, + output_path: String, + factorio_version: &str, + include_optional: bool, + include_optional_all: bool, + target_mod_version: Option, + max_depth: usize, +) -> PyResult { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime.block_on(async { + let start_time = std::time::Instant::now(); + + let mod_id = match extract_mod_id(&mod_url) { + Ok(id) => id, + Err(e) => { + return Ok(DownloadResult { + success: false, + downloaded_mods: vec![], + failed_mods: vec![(mod_url.clone(), e.to_string())], + total_size: 0, + duration: start_time.elapsed().as_secs_f64(), + }); + } + }; + + let config = Config { + target_factorio_version: factorio_version.to_string(), + target_mod_version, + install_optional_deps: include_optional, + install_optional_all_deps: include_optional_all, + max_depth, + }; + + let mut visited = HashSet::new(); + + let download_plan = match resolve_dependencies( + &mod_id, + &config, + &mut visited, + 0, + false, + ).await { + Ok(plan) => plan, + Err(e) => { + return Ok(DownloadResult { + success: false, + downloaded_mods: vec![], + failed_mods: vec![(mod_id, e.to_string())], + total_size: 0, + duration: start_time.elapsed().as_secs_f64(), + }); + } + }; + + let mut unique_plan: Vec = Vec::new(); + let mut seen_mods: HashSet = HashSet::new(); + + for plan in download_plan { + if !seen_mods.contains(&plan.mod_name) { + seen_mods.insert(plan.mod_name.clone()); + unique_plan.push(plan); + } + } + + if unique_plan.is_empty() { + return Ok(DownloadResult { + success: true, + downloaded_mods: vec![], + failed_mods: vec![], + total_size: 0, + duration: start_time.elapsed().as_secs_f64(), + }); + } + + let mut downloaded_mods = Vec::new(); + let mut failed_mods = Vec::new(); + let mut total_size = 0u64; + + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(4)); + let mut tasks = Vec::new(); + + for plan in unique_plan { + let permit = semaphore.clone().acquire_owned().await.unwrap(); + let output_path_clone = output_path.clone(); + + let task = tokio::spawn(async move { + let result = download_mod( + &plan.mod_name, + &plan.version, + &plan.file_name, + &output_path_clone, + ).await; + + drop(permit); + + match result { + Ok(size) => Ok((plan.mod_name.clone(), size)), + Err(e) => Err((plan.mod_name.clone(), e.to_string())), + } + }); + + tasks.push(task); + } + + for task in tasks { + match task.await { + Ok(Ok((mod_name, size))) => { + downloaded_mods.push(mod_name); + total_size += size; + } + Ok(Err((mod_name, error))) => { + failed_mods.push((mod_name, error)); + } + Err(e) => { + failed_mods.push(("unknown".to_string(), e.to_string())); + } + } + } + + let duration = start_time.elapsed().as_secs_f64(); + let success = failed_mods.is_empty(); + + Ok(DownloadResult { + success, + downloaded_mods, + failed_mods, + total_size, + duration, + }) + }) +} + +/// Download multiple mods from a list of URLs +#[pyfunction] +#[pyo3(signature = (mod_urls, output_path, factorio_version="2.0", _include_optional=true, _include_optional_all=false, _max_depth=10, continue_on_error=true))] +fn batch_download_mods( + mod_urls: Vec, + output_path: String, + factorio_version: &str, + _include_optional: bool, + _include_optional_all: bool, + _max_depth: usize, + continue_on_error: bool, +) -> PyResult { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime.block_on(async { + let start_time = std::time::Instant::now(); + let mut all_downloaded = Vec::new(); + let mut all_failed = Vec::new(); + let mut total_size = 0u64; + + for mod_url in mod_urls { + let mod_url_clone = mod_url.clone(); + let output_path_clone = output_path.clone(); + let _factorio_version_clone = factorio_version.to_string(); + + let result = tokio::task::spawn_blocking(move || -> Result { + let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + rt.block_on(async { + let mod_id = extract_mod_id(&mod_url_clone).map_err(|e| e.to_string())?; + let mod_info = get_mod_info(&mod_id).await.map_err(|e| e.to_string())?; + + let release = mod_info.releases.last() + .ok_or_else(|| "No releases found".to_string())?; + + let file_name = format!("{}_{}.zip", mod_id, release.version); + let size = download_mod(&mod_id, &release.version, &file_name, &output_path_clone) + .await.map_err(|e| e.to_string())?; + + Ok(DownloadResult { + success: true, + downloaded_mods: vec![mod_id], + failed_mods: vec![], + total_size: size, + duration: 0.0, + }) + }) + }).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Task join error: {}", e)))? + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Download error: {}", e)))?; + + all_downloaded.extend(result.downloaded_mods); + all_failed.extend(result.failed_mods.clone()); + total_size += result.total_size; + + if !result.success && !continue_on_error { + break; + } + } + + let duration = start_time.elapsed().as_secs_f64(); + let success = all_failed.is_empty(); + + Ok(DownloadResult { + success, + downloaded_mods: all_downloaded, + failed_mods: all_failed, + total_size, + duration, + }) + }) +} + +/// Update mod-list.json with downloaded mods +#[pyfunction] +#[pyo3(signature = (mod_names, mods_directory, enabled=true))] +fn update_mod_list_json( + mod_names: Vec, + mods_directory: String, + enabled: bool, +) -> PyResult { + use std::path::PathBuf; + + let mod_list_path = PathBuf::from(&mods_directory).join("mod-list.json"); + + let mut mod_list: serde_json::Value = if mod_list_path.exists() { + let content = std::fs::read_to_string(&mod_list_path) + .map_err(|e| PyErr::new::(e.to_string()))?; + serde_json::from_str(&content) + .map_err(|e| PyErr::new::(e.to_string()))? + } else { + serde_json::json!({"mods": []}) + }; + + let existing_mods: HashSet = mod_list["mods"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|m| m["name"].as_str().map(|s| s.to_string())) + .collect(); + + let mods_array = mod_list["mods"].as_array_mut().unwrap(); + let mut added_count = 0; + + for mod_name in mod_names { + let clean_mod_name = if mod_name.contains('_') && mod_name.ends_with(".zip") { + mod_name.split('_').next().unwrap_or(&mod_name).to_string() + } else { + mod_name.clone() + }; + + if !existing_mods.contains(&clean_mod_name) { + mods_array.push(serde_json::json!({ + "name": clean_mod_name, + "enabled": enabled + })); + added_count += 1; + } + } + + if added_count > 0 { + let content = serde_json::to_string_pretty(&mod_list) + .map_err(|e| PyErr::new::(e.to_string()))?; + std::fs::write(&mod_list_path, content) + .map_err(|e| PyErr::new::(e.to_string()))?; + } + + Ok(added_count > 0) +} + +/// Python module +#[pymodule] +fn factorio_mod_downloader_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { + // Legacy functions + m.add_function(wrap_pyfunction!(download_mod_with_deps, m)?)?; + m.add_function(wrap_pyfunction!(batch_download_mods, m)?)?; + + // Enhanced functions + m.add_function(wrap_pyfunction!(download_mod_with_deps_enhanced_py, m)?)?; + m.add_function(wrap_pyfunction!(batch_download_mods_enhanced_py, m)?)?; + + // Utility functions + m.add_function(wrap_pyfunction!(update_mod_list_json, m)?)?; + m.add_function(wrap_pyfunction!(parse_batch_file_py, m)?)?; + + // Classes + m.add_class::()?; + Ok(()) +} \ No newline at end of file diff --git a/src/main_download.rs b/src/main_download.rs new file mode 100644 index 0000000..ffd8fe0 --- /dev/null +++ b/src/main_download.rs @@ -0,0 +1,254 @@ +use crate::shared::*; +use std::collections::HashSet; +use std::time::Instant; +use indicatif::{ProgressBar, ProgressStyle, MultiProgress}; +use console::style; +use pyo3::prelude::*; + +// Use the DownloadResult from lib.rs +pub use crate::DownloadResult; + +pub async fn download_single_mod_enhanced( + mod_url: String, + output_path: String, + factorio_version: String, + include_optional: bool, + include_optional_all: bool, + target_mod_version: Option, + max_depth: usize, + update_mod_list: bool, +) -> Result> { + let start_time = Instant::now(); + + let mod_id = extract_mod_id(&mod_url)?; + + // Handle version specification from ModID format + let version_spec = extract_version_spec(&mod_url); + let final_target_version = match version_spec { + Some(spec) if spec == "latest" => None, // latest means get latest compatible version + Some(spec) => Some(spec), // specific version requested + None => target_mod_version, // use version from CLI args if any + }; + + let config = Config { + target_factorio_version: factorio_version.clone(), + target_mod_version: final_target_version, + install_optional_deps: include_optional, + install_optional_all_deps: include_optional_all, + max_depth, + }; + + // Display target Factorio version + println!("{}", style(format!("Target Factorio version: {}", factorio_version)).dim()); + + // === RESOLVING PHASE === + let resolve_start = Instant::now(); + let multi = MultiProgress::new(); + + let resolve_pb = multi.add(ProgressBar::new_spinner()); + resolve_pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + ); + resolve_pb.enable_steady_tick(std::time::Duration::from_millis(80)); + resolve_pb.set_message("🔍 Resolving dependencies..."); + + let mut visited = HashSet::new(); + let download_plan = resolve_dependencies( + &mod_id, + &config, + &mut visited, + 0, + false, + ).await?; + + // Remove duplicates + let mut unique_plan: Vec = Vec::new(); + let mut seen_mods: HashSet = HashSet::new(); + + for plan in download_plan { + if !seen_mods.contains(&plan.mod_name) { + seen_mods.insert(plan.mod_name.clone()); + unique_plan.push(plan); + } + } + + let resolve_time = resolve_start.elapsed(); + resolve_pb.finish_and_clear(); + + if unique_plan.is_empty() { + println!("{}", style("No compatible mods found").red().bold()); + return Ok(DownloadResult { + success: false, + downloaded_mods: vec![], + failed_mods: vec![(mod_id, "No compatible mods found".to_string())], + total_size: 0, + duration: start_time.elapsed().as_secs_f64(), + }); + } + + println!("{} {} in {:.2}s", + style("✅ Resolved").bold().green(), + style(format!("{} packages", unique_plan.len())).cyan().bold(), + resolve_time.as_secs_f64() + ); + + // === DOWNLOADING PHASE === + let download_start = Instant::now(); + let download_pb = multi.add(ProgressBar::new(unique_plan.len() as u64)); + download_pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg} {bytes}/{total_bytes} ({bytes_per_sec}, {eta})") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + .progress_chars("█░") + ); + download_pb.enable_steady_tick(std::time::Duration::from_millis(80)); + + let mut stats = DownloadStats { + installed: Vec::new(), + failed: Vec::new(), + }; + + let mut total_bytes = 0u64; + + for (i, plan) in unique_plan.iter().enumerate() { + download_pb.set_message(format!("📦 Downloading {}", style(&plan.mod_name).cyan().bold())); + + match download_mod(&plan.mod_name, &plan.version, &plan.file_name, &output_path).await { + Ok(size) => { + stats.installed.push((plan.mod_name.clone(), plan.version.clone())); + total_bytes += size; + download_pb.set_length(total_bytes); + download_pb.set_position((i + 1) as u64 * total_bytes / unique_plan.len() as u64); + } + Err(e) => { + stats.failed.push((plan.mod_name.clone(), e.to_string())); + } + } + + download_pb.inc(1); + } + + let download_time = download_start.elapsed(); + download_pb.finish_and_clear(); + + // === INSTALLING PHASE === + let install_pb = multi.add(ProgressBar::new_spinner()); + install_pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + ); + install_pb.enable_steady_tick(std::time::Duration::from_millis(80)); + install_pb.set_message("🔧 Installing packages..."); + + // Simulate install time (files are already written) + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let install_time = download_start.elapsed(); + install_pb.finish_and_clear(); + + // === ENHANCED SUMMARY === + println!("{} {} in {:.0}ms ({:.2} MB/s)", + style("📥 Downloaded").bold().green(), + style(format!("{} packages", stats.installed.len())).cyan().bold(), + download_time.as_millis(), + (total_bytes as f64 / 1024.0 / 1024.0) / download_time.as_secs_f64() + ); + + println!("{} {} in {:.0}ms", + style("🔧 Installed").bold().green(), + style(format!("{} packages", stats.installed.len())).cyan().bold(), + install_time.as_millis() + ); + + // Show installed packages (UV-style + format with enhanced colors) + for (name, version) in &stats.installed { + println!(" {} {}=={}", + style("+").green().bold(), + style(name).white().bold(), + style(version).dim() + ); + } + + // Show failures if any with enhanced error display + if !stats.failed.is_empty() { + println!("\n{} Failed downloads:", style("⚠").yellow().bold()); + for (name, error) in &stats.failed { + println!(" {} {} - {}", + style("-").red().bold(), + style(name).white().bold(), + style(error).red().dim() + ); + } + } + + // Show total size and speed summary + if total_bytes > 0 { + println!("\n{} Total downloaded: {} ({:.2} MB/s average)", + style("📊").blue().bold(), + style(format!("{:.2} MB", total_bytes as f64 / 1024.0 / 1024.0)).cyan().bold(), + (total_bytes as f64 / 1024.0 / 1024.0) / download_time.as_secs_f64() + ); + } + + // Update mod-list.json if requested + if update_mod_list && !stats.installed.is_empty() { + let mod_names: Vec = stats.installed.iter().map(|(name, _)| name.clone()).collect(); + match crate::update_mod_list_json(mod_names, output_path.clone(), true) { + Ok(updated) => { + if updated { + println!("\n{} Updated mod-list.json with {} new mod(s)", + style("✓").green().bold(), + stats.installed.len() + ); + } + } + Err(e) => { + println!("\n{} Failed to update mod-list.json: {}", + style("⚠").yellow().bold(), + e + ); + } + } + } + + Ok(DownloadResult { + success: stats.failed.is_empty(), + downloaded_mods: stats.installed.iter().map(|(name, _)| name.clone()).collect(), + failed_mods: stats.failed, + total_size: total_bytes, + duration: start_time.elapsed().as_secs_f64(), + }) +} + +// PyO3 wrapper function +#[pyfunction(name = "download_mod_with_deps_enhanced")] +#[pyo3(signature = (mod_url, output_path, factorio_version="2.0", include_optional=true, include_optional_all=false, target_mod_version=None, max_depth=10, update_mod_list=false))] +pub fn download_mod_with_deps_enhanced_py( + mod_url: String, + output_path: String, + factorio_version: &str, + include_optional: bool, + include_optional_all: bool, + target_mod_version: Option, + max_depth: usize, + update_mod_list: bool, +) -> PyResult { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime.block_on(download_single_mod_enhanced( + mod_url, + output_path, + factorio_version.to_string(), + include_optional, + include_optional_all, + target_mod_version, + max_depth, + update_mod_list, + )).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Download error: {}", e))) +} diff --git a/src/shared.rs b/src/shared.rs new file mode 100644 index 0000000..bf3a9c4 --- /dev/null +++ b/src/shared.rs @@ -0,0 +1,299 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::pin::Pin; +use std::future::Future; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ModInfo { + pub name: String, + pub title: String, + pub summary: String, + pub downloads_count: u64, + pub owner: String, + pub releases: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Release { + pub version: String, + pub download_url: String, + pub file_name: String, + pub released_at: String, + pub sha1: String, + pub info_json: InfoJson, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct InfoJson { + pub factorio_version: String, + pub dependencies: Vec, +} + +#[derive(Debug, Clone)] +pub struct Dependency { + pub name: String, + pub optional: bool, +} + +#[derive(Debug, Clone)] +pub struct DownloadPlan { + pub mod_name: String, + pub version: String, + pub file_name: String, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub target_factorio_version: String, + pub target_mod_version: Option, + pub install_optional_deps: bool, + pub install_optional_all_deps: bool, + pub max_depth: usize, +} + +#[derive(Debug, Clone)] +pub struct DownloadStats { + pub installed: Vec<(String, String)>, + pub failed: Vec<(String, String)>, +} + +// Shared utility functions +pub fn extract_mod_id(input: &str) -> Result> { + let input = input.trim(); + + // Handle ModID formats: modID, modID@version, modID@latest + if !input.contains("://") { + // Direct mod ID format + let mod_id = input.split('@').next().unwrap_or(input); + if !mod_id.is_empty() { + return Ok(mod_id.to_string()); + } + } + + // Handle URL formats + let url_parts: Vec<&str> = input.split('/').collect(); + if let Some(mod_part) = url_parts.iter().find(|&&part| part.starts_with("mod")) { + if let Some(mod_id) = mod_part.strip_prefix("mod/") { + return Ok(mod_id.split('?').next().unwrap_or(mod_id).to_string()); + } + } + + // Fallback: try to extract from the last part of the URL + if let Some(last_part) = url_parts.last() { + let mod_id = last_part.split('?').next().unwrap_or(last_part); + if !mod_id.is_empty() { + return Ok(mod_id.to_string()); + } + } + + Err("Could not extract mod ID from input".into()) +} + +pub fn extract_version_spec(input: &str) -> Option { + if !input.contains("://") && input.contains('@') { + let parts: Vec<&str> = input.split('@').collect(); + if parts.len() == 2 { + return Some(parts[1].to_string()); + } + } + None +} + +pub async fn get_mod_info(mod_id: &str) -> Result> { + let api_url = format!("https://re146.dev/factorio/mods/modinfo?id={}", mod_id); + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let response = client.get(&api_url).send().await?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status()).into()); + } + + let mod_info: ModInfo = response.json().await?; + Ok(mod_info) +} + +pub async fn download_mod( + mod_name: &str, + version: &str, + file_name: &str, + output_path: &str, +) -> Result> { + let download_url = format!("https://mods-storage.re146.dev/{}/{}.zip", mod_name, version); + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(std::time::Duration::from_secs(120)) + .build()?; + + let response = client.get(&download_url).send().await?; + + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status()).into()); + } + + let content_length = response.content_length().unwrap_or(0); + let bytes = response.bytes().await?; + + // Create output directory if it doesn't exist + std::fs::create_dir_all(output_path)?; + + // Write file + let file_path = std::path::Path::new(output_path).join(file_name); + std::fs::write(&file_path, &bytes)?; + + Ok(content_length) +} + +pub fn is_base_dependency(mod_name: &str) -> bool { + matches!(mod_name, "base" | "core" | "freeplay" | "elevated-rails" | "quality" | "space-age") +} + +pub fn parse_dependencies(deps: &[String]) -> Vec { + deps.iter() + .filter_map(|dep| { + let dep = dep.trim(); + if dep.is_empty() || dep == "base" { + return None; + } + + let optional = dep.starts_with('?'); + let name = if optional { + dep.trim_start_matches('?').trim() + } else { + dep + }; + + // Remove version constraints + let name = name.split_whitespace().next().unwrap_or(name); + let name = name.split(">=").next().unwrap_or(name); + let name = name.split("<=").next().unwrap_or(name); + let name = name.split("=").next().unwrap_or(name); + let name = name.split(">").next().unwrap_or(name); + let name = name.split("<").next().unwrap_or(name); + + if name.is_empty() || is_base_dependency(name) { + return None; + } + + Some(Dependency { + name: name.to_string(), + optional, + }) + }) + .collect() +} + +pub fn find_compatible_release<'a>( + mod_info: &'a ModInfo, + config: &Config, + is_main_mod: bool, +) -> Result<&'a Release, Box> { + // If specific version requested for main mod + if is_main_mod { + if let Some(target_version) = &config.target_mod_version { + if let Some(release) = mod_info.releases.iter().find(|r| r.version == *target_version) { + return Ok(release); + } + return Err(format!("Version {} not found for mod {}", target_version, mod_info.name).into()); + } + } + + // Find latest compatible release (get LAST compatible version, not first) + let compatible_releases: Vec<&Release> = mod_info.releases + .iter() + .filter(|r| is_version_compatible(&r.info_json.factorio_version, &config.target_factorio_version)) + .collect(); + + if let Some(latest_release) = compatible_releases.last() { + return Ok(latest_release); + } + + Err(format!("No compatible release found for mod {} (Factorio {})", mod_info.name, config.target_factorio_version).into()) +} + +pub fn is_version_compatible(mod_version: &str, target_version: &str) -> bool { + // Simple version compatibility check + // This is a simplified version - you might want to implement proper semver comparison + let mod_major = mod_version.split('.').next().unwrap_or("0"); + let target_major = target_version.split('.').next().unwrap_or("0"); + + mod_major == target_major +} + +pub fn resolve_dependencies<'a>( + mod_id: &'a str, + config: &'a Config, + visited: &'a mut HashSet, + depth: usize, + parent_is_optional: bool, +) -> Pin, Box>> + 'a>> { + Box::pin(async move { + if depth > config.max_depth { + return Ok(vec![]); + } + + if visited.contains(mod_id) { + return Ok(vec![]); + } + visited.insert(mod_id.to_string()); + + if is_base_dependency(mod_id) { + return Ok(vec![]); + } + + let mod_info = match get_mod_info(mod_id).await { + Ok(info) => info, + Err(_) => return Ok(vec![]), + }; + + let release = match find_compatible_release(&mod_info, config, depth == 0) { + Ok(r) => r, + Err(_) => return Ok(vec![]), + }; + + let dependencies = parse_dependencies(&release.info_json.dependencies); + + let mut plan = vec![DownloadPlan { + mod_name: mod_info.name.clone(), + version: release.version.clone(), + file_name: release.file_name.clone(), + }]; + + for dep in dependencies { + if is_base_dependency(&dep.name) { + continue; + } + + let should_install = if dep.optional { + if depth == 0 { + config.install_optional_deps + } else if parent_is_optional { + config.install_optional_all_deps + } else { + config.install_optional_all_deps + } + } else { + true + }; + + if should_install { + let dep_plan = resolve_dependencies( + &dep.name, + config, + visited, + depth + 1, + dep.optional, + ).await?; + + plan.extend(dep_plan); + } + } + + Ok(plan) + }) +}