diff --git a/README.md b/README.md index 7d4d29c..77e30d2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Forge - A Home-Manager Like Tool for Fedora -A lightweight configuration management tool for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. +A lightweight Python CLI (managed with `uv`) for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. ## Overview -Forge is a bash script that helps you manage your Fedora system configuration by: +Forge is a Python CLI that helps you manage your Fedora system configuration by: - Managing COPR repositories (enable/disable automatically) - Installing and managing system packages via dnf - Managing dotfiles through symlinks to actual files @@ -14,39 +14,50 @@ Forge is a bash script that helps you manage your Fedora system configuration by ## Installation -1. Clone or download this repository -2. Make the script executable: +1. Clone or download this repository. +2. Install dependencies with `uv` (none beyond the standard library, but this sets up the venv): ```bash - chmod +x forge + uv sync ``` -3. Optionally, move it to a directory in your PATH: +3. Run with `uv`: ```bash - sudo mv forge /usr/local/bin/forge + uv run forge --help + ``` +4. Or use the local shim directly: + ```bash + ./forge --help + ``` +5. Optionally install globally via `uv`: + ```bash + uv tool install . ``` ## Quick Start ```bash # Initialize the configuration structure -./forge init +uv run forge init # Edit the configuration file to add packages and dotfiles nano ~/.config/forge/forge.toml # Apply configuration -./forge switch +uv run forge switch # Check status -./forge status +uv run forge status ``` +You can swap `uv run forge ...` for `./forge ...` if you prefer the local shim. + ## Commands ### `init` Initialize the configuration structure and create `forge.toml`. ```bash -./forge init +uv run forge init +# or ./forge init ``` Creates: @@ -57,7 +68,8 @@ Creates: Apply the current configuration (enable COPR, install packages, deploy dotfiles). ```bash -./forge switch +uv run forge switch +# or ./forge switch ``` This command: @@ -72,7 +84,8 @@ This command: Show current configuration status and information. ```bash -./forge status +uv run forge status +# or ./forge status ``` Displays: @@ -84,7 +97,8 @@ Displays: Show help message with all available commands. ```bash -./forge help +uv run forge help +# or ./forge help ``` ## Configuration @@ -96,23 +110,21 @@ The central configuration file located at `~/.config/forge/forge.toml`: # Forge Configuration File # User-level configuration similar to home-manager/nixos +copr = [ + # "copr.fedorainfracloud.org/username/repository", + # "copr.fedorainfracloud.org/anotheruser/anotherrepo", +] + +packages = [ + # "git", + # "vim", + # "curl", + # "wget", +] + [forge] version = "1.0" -last_switch = null - -[copr] -# COPR repositories to enable -# Just list COPR repository names - presence means enable, absence means don't enable -copr.fedorainfracloud.org/username/repository -copr.fedorainfracloud.org/anotheruser/anotherrepo - -[packages] -# List of packages to install using dnf -# Just list package names - presence means install, absence means don't install -git -vim -curl -wget +last_switch = "" [dotfiles] # Dotfiles to manage with symlinks @@ -135,30 +147,32 @@ backup_dir = "backup" - `version`: Configuration file version - `last_switch`: Timestamp of last switch operation (auto-updated) -#### `[copr]` -List of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. +#### `copr` +Array of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. **Examples:** ```toml -[copr] -copr.fedorainfracloud.org/username/repository -copr.fedorainfracloud.org/anotheruser/anotherrepo +copr = [ + "copr.fedorainfracloud.org/username/repository", + "copr.fedorainfracloud.org/anotheruser/anotherrepo", +] ``` To remove a COPR repository, simply delete the line containing the repository name. Forge will automatically disable it during the next switch. -#### `[packages]` -List of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. +#### `packages` +Array of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. **Examples:** ```toml -[packages] -git -vim -curl -wget -nodejs -npm +packages = [ + "git", + "vim", + "curl", + "wget", + "nodejs", + "npm", +] ``` To remove a package, simply delete the line containing the package name. @@ -191,17 +205,19 @@ Additional configuration options: ## How It Works ### COPR Repository Management -COPR repositories are managed through the `[copr]` section in `forge.toml`: -- **Add COPR repo**: Simply add the repository name on a new line -- **Remove COPR repo**: Delete the line containing the repository name +COPR repositories are managed through the `copr` array in `forge.toml`: +- **Add COPR repo**: Add the repository name to the array +- **Remove COPR repo**: Remove the entry from the array - **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration - **No flags needed**: Just presence/absence of the repository name matters +- Forge stores the previous configuration at `~/.config/forge/forge.toml.prev` and compares it to the current file during `switch`; newly added repos are enabled, removed repos are disabled. ### Package Management -Packages are managed through the `[packages]` section in `forge.toml`: -- **Add package**: Simply add the package name on a new line -- **Remove package**: Delete the line containing the package name +Packages are managed through the `packages` array in `forge.toml`: +- **Add package**: Add the package name to the array +- **Remove package**: Remove the entry from the array - **No flags needed**: Just presence/absence of the package name matters +- The previous configuration snapshot (`forge.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled. ### Dotfile Management Forge uses symlinks to manage dotfiles: @@ -218,23 +234,29 @@ Before creating symlinks, Forge: 3. Removes the original file 4. Creates the symlink to your managed dotfile +`forge.toml` is also backed up before the last switch timestamp is updated. + +The previously applied configuration is stored separately as `~/.config/forge/forge.toml.prev` to compute diffs for COPR and package changes. + ## Examples ### Basic Setup ```bash # Initialize forge -./forge init +uv run forge init # Edit forge.toml to add COPR repos and packages nano ~/.config/forge/forge.toml -# Add to [copr] section: -copr.fedorainfracloud.org/username/cool-repo - -# Add to [packages] section: -git -vim -curl +# Add to the arrays: +# copr = [ +# "copr.fedorainfracloud.org/username/cool-repo", +# ] +# packages = [ +# "git", +# "vim", +# "curl", +# ] # Create your dotfiles directory and add files mkdir -p ~/.config/forge/dotfiles @@ -244,7 +266,7 @@ echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc ".bashrc" = ".bashrc" # Apply configuration -./forge switch +uv run forge switch ``` ### Managing Application Configurations @@ -260,37 +282,36 @@ nano ~/.config/forge/forge.toml ".config/alacritty/alacritty.yml" = "alacritty.yml" # Apply changes -./forge switch +uv run forge switch ``` ### COPR Repository Management Examples ```toml -[copr] -# Development tools COPR -copr.fedorainfracloud.org/development/tools -copr.fedorainfracloud.org/user/neovim-nightly - -# To remove a COPR repo, just delete the line +copr = [ + # Development tools COPR + "copr.fedorainfracloud.org/development/tools", + "copr.fedorainfracloud.org/user/neovim-nightly", +] +# To remove a COPR repo, just delete the entry # Forge will automatically disable it during the next switch ``` ### Package Management Examples ```toml -[packages] -# Development tools -git -vim -nodejs -npm +packages = [ + # Development tools + "git", + "vim", + "nodejs", + "npm", -# System utilities -curl -wget -tree -htop - -# To remove a package, just delete the line -# For example, to remove htop, delete the "htop" line + # System utilities + "curl", + "wget", + "tree", + "htop", +] +# To remove a package, just delete the entry ``` ### Version Control Your Configuration @@ -312,30 +333,35 @@ git commit -m "Updated vim configuration" ## Dependencies +- Python 3.11+ +- `tomlkit` (installed automatically via `uv sync`) +- `uv` for environment and script management - `dnf` - Fedora package manager - `dnf-plugins-core` - For COPR repository management -- `sed` - For updating configuration file (usually pre-installed) ## Migration from Previous Version -If you were using the old version of forge with `git = true` format: +If you were using the old version of forge with `git = true` or bare keys under `[packages]`/`[copr]`: -1. Your existing configuration will not be automatically migrated -2. Run `./forge init` to create the new `forge.toml` structure -3. Convert your package configuration from: +1. Your existing configuration will not be automatically migrated, but the CLI will still read the legacy format. +2. Run `uv run forge init` (or `./forge init`) to create the new `forge.toml` structure. +3. Convert to arrays: ```toml - # Old format + # Old formats + [packages] git = true vim = true - ``` - to: - ```toml - # New format + # or + [packages] git vim + + # New format + packages = ["git", "vim"] + copr = ["copr.fedorainfracloud.org/username/repository"] ``` 4. Move your existing dotfiles from the old directory to `~/.config/forge/dotfiles/` ## License -This project is open source. Feel free to contribute or report issues. \ No newline at end of file +This project is open source. Feel free to contribute or report issues. diff --git a/forge b/forge index 3b3c8a9..c83e04c 100755 --- a/forge +++ b/forge @@ -1,472 +1,24 @@ -#!/bin/bash +#!/usr/bin/env python3 +from __future__ import annotations -# forge - A home-manager like script for Fedora -# Provides dotfile management and package installation functionality +import sys +from pathlib import Path -set -euo pipefail -# Configuration -FORGE_DIR="${FORGE_DIR:-$HOME/.config/forge}" -FORGE_TOML="$FORGE_DIR/forge.toml" +def main() -> None: + here = Path(__file__).resolve().parent + src_dir = here / "src" + if src_dir.exists(): + sys.path.insert(0, str(src_dir)) -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color + try: + from forge.cli import main as forge_main + except ImportError as exc: # pragma: no cover - fallback error path + sys.stderr.write(f"Failed to import forge CLI: {exc}\n") + sys.exit(1) -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} + forge_main() -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Initialize command - creates forge.toml and .config directory -init() { - log_info "Initializing forge..." - - # Create directory - mkdir -p "$FORGE_DIR" - - # Create initial forge.toml if it doesn't exist - if [[ ! -f "$FORGE_TOML" ]]; then - cat >"$FORGE_TOML" <<'EOF' -# Forge Configuration File -# User-level configuration similar to home-manager/nixos - -[forge] -version = "1.0" -last_switch = null - -[copr] -# COPR repositories to enable -# Just list COPR repository names - presence means enable, absence means don't enable -# Examples: -# copr.fedorainfracloud.org/username/repository -# copr.fedorainfracloud.org/anotheruser/anotherrepo - -[packages] -# List of packages to install using dnf -# Just list package names - presence means install, absence means don't install -# Examples: -# git -# vim -# curl - -[dotfiles] -# Dotfiles to manage with symlinks -# Format: "target_path" = "source_path" -# target_path: where the symlink should be created (relative to home directory) -# source_path: where the actual file is stored (relative to forge directory) -# Note: source_path automatically prefixed with "dotfiles/" if not present -# Examples: -# ".bashrc" = ".bashrc" -# ".config/vimrc" = "vimrc" -# ".config/alacritty/alacritty.yml" = "alacritty.yml" - -[options] -# Additional options -backup = true -backup_dir = "backup" -EOF - log_success "Created forge.toml: $FORGE_TOML" - fi - - log_success "Initialization complete!" - log_info "Edit $FORGE_TOML to configure packages and dotfiles" - log_info "Run 'forge switch' to apply your configuration" -} - -# Parse TOML file (basic implementation) -parse_toml() { - local section="" - while IFS= read -r line; do - # Skip comments and empty lines - [[ -z "$line" || "$line" == \#* ]] && continue - - # Check for section headers - if [[ "$line" =~ ^\[(.+)\]$ ]]; then - section="${BASH_REMATCH[1]}" - continue - fi - - # Parse key-value pairs for non-packages sections - if [[ "$section" != "packages" && "$line" =~ ^([^=]+)=(.+)$ ]]; then - local key="${BASH_REMATCH[1]}" - local value="${BASH_REMATCH[2]}" - - # Trim leading/trailing whitespace from key and value - key=$(echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - value=$(echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - - # Remove quotes from quoted strings - if [[ "$key" =~ ^\"(.*)\"$ ]]; then - key="${BASH_REMATCH[1]}" - fi - if [[ "$value" =~ ^\"(.*)\"$ ]]; then - value="${BASH_REMATCH[1]}" - fi - - echo "$section:$key=$value" - # Parse package names (just keys without values in packages section) - elif [[ "$section" == "packages" && "$line" =~ ^([^=]+)$ ]]; then - local package="${BASH_REMATCH[1]// /}" - echo "$section:$package" - # Parse COPR repository names (just keys without values in copr section) - elif [[ "$section" == "copr" && "$line" =~ ^([^=]+)$ ]]; then - local copr_repo="${BASH_REMATCH[1]// /}" - echo "$section:$copr_repo" - fi - done <"$FORGE_TOML" -} - -# Enable COPR repositories from forge.toml -enable_copr_repos() { - log_info "Enabling COPR repositories..." - - local copr_enabled=false - - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local copr_repo="${entry#*:}" - - if [[ "$section" == "copr" && -n "$copr_repo" ]]; then - copr_enabled=true - log_info "Enabling COPR repository: $copr_repo" - if sudo dnf copr enable -y "$copr_repo"; then - log_success "Enabled COPR: $copr_repo" - else - log_error "Failed to enable COPR: $copr_repo" - fi - fi - done < <(parse_toml) - - if [[ "$copr_enabled" == false ]]; then - log_info "No COPR repositories to enable" - fi -} - -# Disable COPR repositories that are no longer in forge.toml -disable_copr_repos() { - log_info "Checking for COPR repositories to disable..." - - # Get currently enabled COPR repos - local enabled_repos=$(sudo dnf copr list --enabled 2>/dev/null | grep -E "copr\.fedorainfracloud\.org" | awk '{print $1}' || true) - - # Get configured COPR repos from forge.toml - local configured_repos="" - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local copr_repo="${entry#*:}" - - if [[ "$section" == "copr" && -n "$copr_repo" ]]; then - configured_repos="$configured_repos $copr_repo" - fi - done < <(parse_toml) - - # Disable repos that are enabled but not configured - for repo in $enabled_repos; do - if [[ ! " $configured_repos " =~ " $repo " ]]; then - log_info "Disabling COPR repository: $repo" - if sudo dnf copr disable -y "$repo"; then - log_success "Disabled COPR: $repo" - else - log_error "Failed to disable COPR: $repo" - fi - fi - done -} - -# Install packages from forge.toml -install_packages() { - log_info "Installing packages..." - - local packages_installed=false - - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local package="${entry#*:}" - - if [[ "$section" == "packages" && -n "$package" ]]; then - packages_installed=true - log_info "Installing package: $package" - if sudo dnf install -y "$package"; then - log_success "Installed $package" - else - log_error "Failed to install $package" - fi - fi - done < <(parse_toml) - - if [[ "$packages_installed" == false ]]; then - log_info "No packages to install" - fi -} - -# Deploy dotfiles using symlinks -deploy_dotfiles() { - log_info "Deploying dotfiles..." - - local dotfiles_deployed=false - local dotfiles_dir="$FORGE_DIR/dotfiles" - - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local key_value="${entry#*:}" - local target="${key_value%%=*}" - local source="${key_value##*=}" - - if [[ "$section" == "dotfiles" ]]; then - dotfiles_deployed=true - - # Normalize source path - if it doesn't start with dotfiles/, add it - if [[ ! "$source" =~ ^dotfiles/ ]]; then - source="dotfiles/$source" - fi - - local target_path="$HOME/$target" - local source_path="$FORGE_DIR/$source" - - # Verify source file exists - if [[ ! -f "$source_path" && ! -d "$source_path" ]]; then - log_error "Source file not found: $source_path" - continue - fi - - # Create target directory if it doesn't exist - local target_dir=$(dirname "$target_path") - mkdir -p "$target_dir" - - # Handle existing file/symlink - if [[ -e "$target_path" || -L "$target_path" ]]; then - # Check if it's already a correct symlink - if [[ -L "$target_path" ]]; then - local current_target=$(readlink "$target_path") - if [[ "$current_target" == "$source_path" ]]; then - log_info "Symlink already correct: $target_path" - continue - fi - fi - - # Backup existing file if it exists and is not a symlink - if [[ -e "$target_path" && ! -L "$target_path" ]]; then - local backup_dir="$FORGE_DIR/backup" - mkdir -p "$backup_dir" - local backup_path="$backup_dir/$(basename "$target").$(date +%Y%m%d_%H%M%S).bak" - cp -r "$target_path" "$backup_path" - log_info "Backed up $target_path to $backup_path" - fi - - # Remove existing file/symlink - rm -rf "$target_path" - fi - - # Create relative symlink for better portability - local relative_source - if [[ -d "$source_path" ]]; then - relative_source=$(realpath --relative-to="$target_dir" "$source_path") - else - relative_source=$(realpath --relative-to="$target_dir" "$source_path") - fi - - # Create symlink using relative path - if ln -s "$relative_source" "$target_path"; then - log_success "Created symlink: $target_path -> $relative_source" - else - log_error "Failed to create symlink: $target_path -> $relative_source" - fi - fi - done < <(parse_toml) - - if [[ "$dotfiles_deployed" == false ]]; then - log_info "No dotfiles to deploy" - fi -} - -# Switch command - apply configuration -switch() { - log_info "Starting forge switch..." - - # Check if forge.toml exists - if [[ ! -f "$FORGE_TOML" ]]; then - log_error "forge.toml not found. Run 'forge init' first." - exit 1 - fi - - # Enable COPR repositories - enable_copr_repos - - # Disable COPR repositories that are no longer configured - disable_copr_repos - - # Install packages - install_packages - - # Deploy dotfiles - deploy_dotfiles - - # Update last_switch timestamp - if command -v sed >/dev/null 2>&1; then - sed -i "s/last_switch = .*/last_switch = \"$(date -Iseconds)\"/" "$FORGE_TOML" - fi - - log_success "Switch completed successfully!" -} - -# Status command - show current configuration -status() { - log_info "Forge Status" - echo " Config directory: $FORGE_DIR" - echo " Config file: $FORGE_TOML" - - if [[ -f "$FORGE_TOML" ]]; then - echo - log_info "Configuration:" - - # Show last switch time - local last_switch=$(grep "last_switch" "$FORGE_TOML" | cut -d'=' -f2 | tr -d ' "') - if [[ -n "$last_switch" && "$last_switch" != "null" ]]; then - echo " Last switch: $last_switch" - else - echo " Last switch: Never" - fi - - echo - log_info "COPR Repositories:" - local copr_count=0 - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local copr_repo="${entry#*:}" - - if [[ "$section" == "copr" && -n "$copr_repo" ]]; then - echo " $copr_repo" - ((copr_count++)) - fi - done < <(parse_toml) - - if [[ $copr_count -eq 0 ]]; then - echo " No COPR repositories configured" - else - echo " Total: $copr_count COPR repositories" - fi - - echo - log_info "Packages:" - local package_count=0 - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local package="${entry#*:}" - - if [[ "$section" == "packages" && -n "$package" ]]; then - echo " $package" - ((package_count++)) - fi - done < <(parse_toml) - - if [[ $package_count -eq 0 ]]; then - echo " No packages configured" - else - echo " Total: $package_count packages" - fi - - echo - log_info "Dotfiles:" - local dotfile_count=0 - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local key_value="${entry#*:}" - local target="${key_value%%=*}" - local source="${key_value##*=}" - - if [[ "$section" == "dotfiles" ]]; then - echo " $target -> $source" - ((dotfile_count++)) - fi - done < <(parse_toml) - - if [[ $dotfile_count -eq 0 ]]; then - echo " No dotfiles configured" - else - echo " Total: $dotfile_count dotfiles" - fi - else - echo " No configuration file found" - fi -} - -# Show help -show_help() { - cat < - - COMMANDS: - init Initialize configuration structure (creates forge.toml) - switch Apply configuration (enable COPR, install packages, deploy dotfiles) - status Show current configuration status - help Show this help message - - FEATURES: - - TOML-based configuration in forge.toml - - COPR repository management (enable/disable automatically) - - Package management via dnf - - Dotfile management via symlinks - - Automatic backup of existing files - - User-level configuration like home-manager/nixos - - FILES: - $FORGE_DIR/ Main configuration directory - $FORGE_TOML Configuration file for COPR, packages and dotfiles - - EXAMPLES: - forge init - # Edit $FORGE_TOML to add COPR repos, packages and dotfiles - forge switch - forge status - -EOF -} - -# Main script logic -main() { - case "${1:-}" in - "init") - init - ;; - "switch") - switch - ;; - "status") - status - ;; - "help" | "--help" | "-h") - show_help - ;; - "") - log_error "No command specified. Use 'help' for usage information." - exit 1 - ;; - *) - log_error "Unknown command: $1" - show_help - exit 1 - ;; - esac -} - -# Run main function with all arguments -main "$@" +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..462e47b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "forge" +version = "0.1.0" +description = "A home-manager style Fedora configuration helper." +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["tomlkit>=0.12"] + +[project.scripts] +forge = "forge.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[dependency-groups] +dev = ["pytest>=7.4"] diff --git a/src/forge/__init__.py b/src/forge/__init__.py new file mode 100644 index 0000000..a762498 --- /dev/null +++ b/src/forge/__init__.py @@ -0,0 +1 @@ +# Forge package diff --git a/src/forge/__pycache__/__init__.cpython-313.pyc b/src/forge/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b85de53 Binary files /dev/null and b/src/forge/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/forge/__pycache__/__init__.cpython-314.pyc b/src/forge/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..8a8ffc6 Binary files /dev/null and b/src/forge/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/forge/__pycache__/cli.cpython-313.pyc b/src/forge/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..047e443 Binary files /dev/null and b/src/forge/__pycache__/cli.cpython-313.pyc differ diff --git a/src/forge/__pycache__/cli.cpython-314.pyc b/src/forge/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000..f5bea85 Binary files /dev/null and b/src/forge/__pycache__/cli.cpython-314.pyc differ diff --git a/src/forge/cli.py b/src/forge/cli.py new file mode 100644 index 0000000..b2d0819 --- /dev/null +++ b/src/forge/cli.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import tomlkit +from tomlkit.exceptions import TOMLKitError +from tomlkit.items import Array, Table + +BLUE = "\033[0;34m" +GREEN = "\033[0;32m" +YELLOW = "\033[1;33m" +RED = "\033[0;31m" +RESET = "\033[0m" + +DEFAULT_CONFIG = """# Forge Configuration File +# User-level configuration similar to home-manager/nixos + +copr = [ + # "copr.fedorainfracloud.org/username/repository", + # "copr.fedorainfracloud.org/anotheruser/anotherrepo", +] + +packages = [ + # "git", + # "vim", + # "curl", +] + +[forge] +version = "1.0" +last_switch = "" + +[dotfiles] +# Dotfiles to manage with symlinks +# Format: "target_path" = "source_path" +# target_path: where the symlink should be created (relative to home directory) +# source_path: where the actual file is stored (relative to forge directory) +# Note: source_path automatically prefixed with "dotfiles/" if not present +# Examples: +# ".bashrc" = ".bashrc" +# ".config/vimrc" = "vimrc" +# ".config/alacritty/alacritty.yml" = "alacritty.yml" + +[options] +# Additional options +backup = true +backup_dir = "backup" +""" + + +@dataclass +class ForgeConfig: + copr: list[str] + packages: list[str] + dotfiles: dict[str, str] + options: dict[str, Any] + forge_fields: dict[str, Any] + document: Optional[tomlkit.TOMLDocument] + legacy: bool = False + + +def log(prefix: str, color: str, message: str) -> None: + print(f"{color}{prefix}{RESET} {message}") + + +def log_info(message: str) -> None: + log("[INFO]", BLUE, message) + + +def log_success(message: str) -> None: + log("[SUCCESS]", GREEN, message) + + +def log_warning(message: str) -> None: + log("[WARNING]", YELLOW, message) + + +def log_error(message: str) -> None: + log("[ERROR]", RED, message) + + +def strip_quotes(value: str) -> str: + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + return value[1:-1] + return value + + +def parse_value(value: str) -> Any: + lowered = value.lower() + if lowered in {"true", "false"}: + return lowered == "true" + if lowered in {"null", "none"}: + return None + return strip_quotes(value) + + +def parse_legacy_config(config_path: Path) -> ForgeConfig: + copr: list[str] = [] + packages: list[str] = [] + dotfiles: dict[str, str] = {} + options: dict[str, Any] = {} + forge_fields: dict[str, Any] = {} + section = None + + with config_path.open() as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + section_match = re.match(r"\[(.+)]$", line) + if section_match: + section = section_match.group(1).strip() + continue + + if section is None: + continue + + if section in {"packages", "copr"}: + entry = line.split("=", 1)[0].strip() + if entry: + target = packages if section == "packages" else copr + target.append(strip_quotes(entry)) + continue + + if section == "dotfiles": + if "=" not in line: + continue + target_raw, source_raw = line.split("=", 1) + target = strip_quotes(target_raw.strip()) + source = strip_quotes(source_raw.strip()) + if target and source: + dotfiles[target] = source + continue + + if "=" not in line: + continue + + key_raw, value_raw = line.split("=", 1) + key = strip_quotes(key_raw.strip()) + value = parse_value(value_raw.strip()) + if not key: + continue + + if section == "options": + options[key] = value + elif section == "forge": + forge_fields[key] = value + + merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"} + merged_options.update(options) + + if not merged_options.get("backup_dir"): + merged_options["backup_dir"] = "backup" + + return ForgeConfig( + copr=copr, + packages=packages, + dotfiles=dotfiles, + options=merged_options, + forge_fields=forge_fields, + document=None, + legacy=True, + ) + + +def empty_config() -> ForgeConfig: + return ForgeConfig( + copr=[], + packages=[], + dotfiles={}, + options={"backup": True, "backup_dir": "backup"}, + forge_fields={}, + document=None, + legacy=False, + ) + + +def parse_document(text: str) -> tomlkit.TOMLDocument | None: + try: + return tomlkit.parse(text) + except TOMLKitError: + sanitized = re.sub(r"=\s*null", '= ""', text) + if sanitized != text: + try: + return tomlkit.parse(sanitized) + except TOMLKitError: + pass + return None + + +def _coerce_list(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, Array): + raw = list(raw) + if isinstance(raw, list): + return [str(item) for item in raw if str(item).strip()] + return [] + + +def _parse_table_list(raw: Any, list_key: str) -> list[str]: + if isinstance(raw, list): + return _coerce_list(raw) + if isinstance(raw, (dict, Table)): + items: list[str] = [] + if list_key in raw: + items.extend(_coerce_list(raw[list_key])) + for key, value in raw.items(): + if key == list_key: + continue + if isinstance(value, bool) and value: + items.append(str(key)) + return items + return [] + + +def parse_config(config_path: Path) -> ForgeConfig: + try: + raw_text = config_path.read_text() + document = parse_document(raw_text) + except FileNotFoundError: + document = None + + if document is None: + log_warning("Configuration is not valid TOML; using legacy parser.") + return parse_legacy_config(config_path) + + copr_raw = document.get("copr", []) + packages_raw = document.get("packages", []) + dotfiles_raw = document.get("dotfiles", {}) + options_raw = document.get("options", {}) + forge_raw = document.get("forge", {}) + + if not copr_raw and isinstance(forge_raw, (dict, Table)) and "copr" in forge_raw: + copr_raw = forge_raw.get("copr", copr_raw) + if not packages_raw and isinstance(forge_raw, (dict, Table)) and "packages" in forge_raw: + packages_raw = forge_raw.get("packages", packages_raw) + + copr = _parse_table_list(copr_raw, "repos") + packages = _parse_table_list(packages_raw, "names") + + dotfiles: dict[str, str] = {} + if isinstance(dotfiles_raw, (dict, Table)): + for target, source in dotfiles_raw.items(): + if source is None: + continue + dotfiles[str(target)] = str(source) + + options: dict[str, Any] = {} + if isinstance(options_raw, (dict, Table)): + options.update({str(key): value for key, value in options_raw.items()}) + + merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"} + merged_options.update(options) + + forge_fields: dict[str, Any] = {} + if isinstance(forge_raw, (dict, Table)): + forge_fields.update({str(key): value for key, value in forge_raw.items()}) + + return ForgeConfig( + copr=copr, + packages=packages, + dotfiles=dotfiles, + options=merged_options, + forge_fields=forge_fields, + document=document, + legacy=False, + ) + + +def load_config(config_path: Path) -> ForgeConfig: + if not config_path.exists(): + return empty_config() + try: + return parse_config(config_path) + except FileNotFoundError: + return empty_config() + + +def diff_items(current: list[str], previous: list[str]) -> tuple[list[str], list[str]]: + current_set = {item for item in current if item} + previous_set = {item for item in previous if item} + added = sorted(current_set - previous_set) + removed = sorted(previous_set - current_set) + return added, removed + + +def get_paths() -> tuple[Path, Path, Path]: + forge_dir = Path(os.environ.get("FORGE_DIR", Path.home() / ".config" / "forge")).expanduser() + forge_toml = forge_dir / "forge.toml" + previous_toml = forge_dir / "forge.toml.prev" + return forge_dir, forge_toml, previous_toml + + +def init_command() -> None: + forge_dir, forge_toml, _ = get_paths() + log_info("Initializing forge...") + forge_dir.mkdir(parents=True, exist_ok=True) + + if forge_toml.exists(): + log_warning(f"Configuration already exists at {forge_toml}") + else: + forge_toml.write_text(DEFAULT_CONFIG) + log_success(f"Created forge.toml: {forge_toml}") + + log_success("Initialization complete!") + log_info(f"Edit {forge_toml} to configure packages and dotfiles") + log_info("Run 'forge switch' to apply your configuration") + + +def run_command(command: list[str]) -> bool: + try: + result = subprocess.run(command, check=False) + except FileNotFoundError: + log_error(f"Command not found: {' '.join(command)}") + return False + return result.returncode == 0 + + +def enable_copr_repos(repos: list[str]) -> None: + log_info("Enabling COPR repositories...") + if not repos: + log_info("No COPR repositories to enable") + return + + for copr_repo in repos: + log_info(f"Enabling COPR repository: {copr_repo}") + if run_command(["sudo", "dnf", "copr", "enable", "-y", copr_repo]): + log_success(f"Enabled COPR: {copr_repo}") + else: + log_error(f"Failed to enable COPR: {copr_repo}") + + +def list_enabled_copr() -> set[str]: + try: + result = subprocess.run( + ["sudo", "dnf", "copr", "list", "--enabled"], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + log_error("dnf not found while listing enabled COPR repositories") + return set() + + if result.returncode != 0: + log_warning("Unable to list enabled COPR repositories") + return set() + + enabled: set[str] = set() + for line in result.stdout.splitlines(): + if "copr.fedorainfracloud.org" in line: + repo = line.split()[0] + enabled.add(repo) + return enabled + + +def disable_copr_repos(configured: set[str], explicit_removed: set[str] | None = None) -> None: + log_info("Checking for COPR repositories to disable...") + enabled = list_enabled_copr() + to_disable: set[str] = {repo for repo in enabled if repo not in configured} + if explicit_removed: + to_disable.update(explicit_removed) + + if not to_disable: + log_info("No COPR repositories to disable") + return + + for repo in sorted(to_disable): + log_info(f"Disabling COPR repository: {repo}") + if run_command(["sudo", "dnf", "copr", "disable", "-y", repo]): + log_success(f"Disabled COPR: {repo}") + else: + log_error(f"Failed to disable COPR: {repo}") + + +def install_packages(packages: list[str]) -> None: + log_info("Installing packages...") + if not packages: + log_info("No packages to install") + return + + for package in packages: + log_info(f"Installing package: {package}") + if run_command(["sudo", "dnf", "install", "-y", package]): + log_success(f"Installed {package}") + else: + log_error(f"Failed to install {package}") + + +def remove_packages(packages: list[str]) -> None: + log_info("Removing packages...") + if not packages: + log_info("No packages to remove") + return + + for package in packages: + log_info(f"Removing package: {package}") + if run_command(["sudo", "dnf", "remove", "-y", package]): + log_success(f"Removed {package}") + else: + log_error(f"Failed to remove {package}") + + +def backup_target(target_path: Path, backup_dir: Path) -> None: + timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / f"{target_path.name}.{timestamp}.bak" + + if target_path.is_dir(): + shutil.copytree(target_path, backup_path) + else: + shutil.copy2(target_path, backup_path) + + log_info(f"Backed up {target_path} to {backup_path}") + + +def remove_existing(target_path: Path) -> None: + if target_path.is_symlink() or target_path.is_file(): + target_path.unlink() + elif target_path.is_dir(): + shutil.rmtree(target_path) + + +def deploy_dotfiles(config: ForgeConfig, forge_dir: Path) -> None: + log_info("Deploying dotfiles...") + if not config.dotfiles: + log_info("No dotfiles to deploy") + return + + backup_enabled = bool(config.options.get("backup", True)) + backup_dir_name = str(config.options.get("backup_dir", "backup")) or "backup" + backup_dir = forge_dir / backup_dir_name + + for target, source in config.dotfiles.items(): + source_path = forge_dir / (source if source.startswith("dotfiles/") else f"dotfiles/{source}") + target_path = Path.home() / target + + if not source_path.exists(): + log_error(f"Source file not found: {source_path}") + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + + if target_path.exists() or target_path.is_symlink(): + if target_path.is_symlink(): + try: + if target_path.resolve() == source_path.resolve(): + log_info(f"Symlink already correct: {target_path}") + continue + except OSError: + pass + + if backup_enabled and target_path.exists() and not target_path.is_symlink(): + backup_target(target_path, backup_dir) + + remove_existing(target_path) + + relative_source = os.path.relpath(source_path, target_path.parent) + try: + target_path.symlink_to(relative_source) + log_success(f"Created symlink: {target_path} -> {relative_source}") + except OSError as exc: + log_error(f"Failed to create symlink: {target_path} -> {relative_source} ({exc})") + + +def update_last_switch(config_path: Path, options: dict[str, Any]) -> None: + backup_enabled = bool(options.get("backup", True)) + backup_dir_name = str(options.get("backup_dir", "backup") or "backup") + backup_dir = config_path.parent / backup_dir_name + + if backup_enabled and config_path.exists(): + try: + backup_target(config_path, backup_dir) + except OSError as exc: + log_warning(f"Failed to back up forge.toml: {exc}") + + timestamp = dt.datetime.now().astimezone() + raw_text = config_path.read_text() + document = parse_document(raw_text) + if document is None: + # Fall back to legacy in-place update without rewriting structure. + ts_str = timestamp.isoformat(timespec="seconds") + lines = raw_text.splitlines() + updated = False + for idx, line in enumerate(lines): + if re.match(r"\s*last_switch\s*=", line): + lines[idx] = f'last_switch = "{ts_str}"' + updated = True + break + if not updated: + lines.append(f'last_switch = "{ts_str}"') + config_path.write_text("\n".join(lines) + "\n") + return + + forge_table = document.get("forge") + if not isinstance(forge_table, Table): + forge_table = tomlkit.table() + document["forge"] = forge_table + + forge_table["last_switch"] = timestamp + config_path.write_text(tomlkit.dumps(document)) + + +def save_previous_config(current_path: Path, previous_path: Path) -> None: + try: + shutil.copy2(current_path, previous_path) + except OSError as exc: + log_warning(f"Failed to write previous configuration snapshot: {exc}") + + +def switch_command() -> None: + forge_dir, forge_toml, previous_toml = get_paths() + if not forge_toml.exists(): + log_error("forge.toml not found. Run 'forge init' first.") + sys.exit(1) + + current_config = parse_config(forge_toml) + previous_config = load_config(previous_toml) + + copr_added, copr_removed = diff_items(current_config.copr, previous_config.copr) + packages_added, packages_removed = diff_items(current_config.packages, previous_config.packages) + + enable_copr_repos(copr_added if copr_added else current_config.copr) + disable_copr_repos(set(current_config.copr), set(copr_removed)) + install_packages(packages_added if packages_added else current_config.packages) + remove_packages(packages_removed) + deploy_dotfiles(current_config, forge_dir) + update_last_switch(forge_toml, current_config.options) + save_previous_config(forge_toml, previous_toml) + log_success("Switch completed successfully!") + + +def status_command() -> None: + forge_dir, forge_toml, _ = get_paths() + log_info("Forge Status") + print(f" Config directory: {forge_dir}") + print(f" Config file: {forge_toml}") + + if not forge_toml.exists(): + print(" No configuration file found") + return + + config = parse_config(forge_toml) + + print() + log_info("Configuration:") + last_switch = config.forge_fields.get("last_switch") + if last_switch and str(last_switch).lower() != "null": + print(f" Last switch: {last_switch}") + else: + print(" Last switch: Never") + + print() + log_info("COPR Repositories:") + if config.copr: + for repo in config.copr: + print(f" {repo}") + print(f" Total: {len(config.copr)} COPR repositories") + else: + print(" No COPR repositories configured") + + print() + log_info("Packages:") + if config.packages: + for package in config.packages: + print(f" {package}") + print(f" Total: {len(config.packages)} packages") + else: + print(" No packages configured") + + print() + log_info("Dotfiles:") + if config.dotfiles: + for target, source in config.dotfiles.items(): + print(f" {target} -> {source}") + print(f" Total: {len(config.dotfiles)} dotfiles") + else: + print(" No dotfiles configured") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="forge", + description="Forge - A home-manager like script for Fedora", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("init", help="Initialize configuration structure") + subparsers.add_parser("switch", help="Apply configuration") + subparsers.add_parser("status", help="Show current configuration status") + subparsers.add_parser("help", help="Show help message") + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "init": + init_command() + elif args.command == "switch": + switch_command() + elif args.command == "status": + status_command() + elif args.command == "help": + parser.print_help() + else: + log_error(f"Unknown command: {args.command}") + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..432987a Binary files /dev/null and b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7bc0486 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,88 @@ +import datetime as dt +from pathlib import Path + +import tomlkit + +from forge import cli + + +def test_parse_config_arrays(tmp_path: Path) -> None: + config_path = tmp_path / "forge.toml" + config_path.write_text( + """ +copr = ["copr.fedorainfracloud.org/user/repo"] +packages = ["git", "vim"] + +[forge] +version = "1.0" +last_switch = "" + +[dotfiles] +".bashrc" = ".bashrc" + +[options] +backup = true +backup_dir = "backup" + """.strip() + ) + + config = cli.parse_config(config_path) + + assert config.copr == ["copr.fedorainfracloud.org/user/repo"] + assert config.packages == ["git", "vim"] + assert config.dotfiles == {".bashrc": ".bashrc"} + assert config.options["backup"] is True + assert config.legacy is False + + +def test_parse_config_legacy_format(tmp_path: Path) -> None: + config_path = tmp_path / "forge.toml" + config_path.write_text( + """ +[packages] +git +vim + +[copr] +copr.fedorainfracloud.org/user/repo + +[dotfiles] +".bashrc" = ".bashrc" + """.strip() + ) + + config = cli.parse_config(config_path) + + assert config.packages == ["git", "vim"] + assert config.copr == ["copr.fedorainfracloud.org/user/repo"] + assert config.dotfiles == {".bashrc": ".bashrc"} + assert config.legacy is True + + +def test_diff_items() -> None: + added, removed = cli.diff_items(["a", "b"], ["b", "c"]) + assert added == ["a"] + assert removed == ["c"] + + +def test_update_last_switch_creates_backup(tmp_path: Path) -> None: + config_dir = tmp_path + config_path = config_dir / "forge.toml" + config_path.write_text( + """ +[forge] +version = "1.0" +last_switch = "" + """.strip() + ) + + options = {"backup": True, "backup_dir": "backup"} + cli.update_last_switch(config_path, options) + + doc = tomlkit.parse(config_path.read_text()) + assert isinstance(doc["forge"]["last_switch"], dt.datetime) + + backup_dir = config_dir / "backup" + backups = list(backup_dir.iterdir()) + assert backups, "expected a backup of forge.toml to be created" + assert any(path.name.startswith("forge.toml.") for path in backups) diff --git a/tmpcfg.toml b/tmpcfg.toml new file mode 100644 index 0000000..180e6a8 --- /dev/null +++ b/tmpcfg.toml @@ -0,0 +1,14 @@ + +[forge] +version = "1.0" +last_switch = "" + +copr = ["copr.fedorainfracloud.org/user/repo"] +packages = ["git", "vim"] + +[dotfiles] +".bashrc" = ".bashrc" + +[options] +backup = true +backup_dir = "backup" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2121675 --- /dev/null +++ b/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "forge" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "tomlkit" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "tomlkit", specifier = ">=0.12" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=7.4" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +]