From da6d24c74f8b49c2bd0bee457c6b5a7247576285 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 24 Dec 2025 07:56:11 +0200 Subject: [PATCH] init --- README.md | 206 +++--- forge | 482 +------------- pyproject.toml | 20 + src/forge/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 158 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 160 bytes src/forge/__pycache__/cli.cpython-313.pyc | Bin 0 -> 27871 bytes src/forge/__pycache__/cli.cpython-314.pyc | Bin 0 -> 31794 bytes src/forge/cli.py | 625 ++++++++++++++++++ .../test_config.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11401 bytes tests/test_config.py | 88 +++ tmpcfg.toml | 14 + uv.lock | 92 +++ 13 files changed, 973 insertions(+), 555 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/forge/__init__.py create mode 100644 src/forge/__pycache__/__init__.cpython-313.pyc create mode 100644 src/forge/__pycache__/__init__.cpython-314.pyc create mode 100644 src/forge/__pycache__/cli.cpython-313.pyc create mode 100644 src/forge/__pycache__/cli.cpython-314.pyc create mode 100644 src/forge/cli.py create mode 100644 tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/test_config.py create mode 100644 tmpcfg.toml create mode 100644 uv.lock 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 0000000000000000000000000000000000000000..b85de530786ed033617868e5735239a554330592 GIT binary patch literal 158 zcmey&%ge>Uz`#&C%{xC!t0Qo;AlmGw# literal 0 HcmV?d00001 diff --git a/src/forge/__pycache__/__init__.cpython-314.pyc b/src/forge/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a8ffc60ce3ad9c4f4753aeafffc7724964cd113 GIT binary patch literal 160 zcmdPq9gC?WjN`@jP1_p-DAjw;{ z`elhl`WgATsrp5Uc`5nn`6U_p0Y&*)smUe9`pNkzsrqU8Md_*f#YM?bR(yPBUS>&r myk0@&Ee@O9{FKt1RJ$Tp1_lO@eZ?TgCuT-Q#v*101_l6yyd}v1 literal 0 HcmV?d00001 diff --git a/src/forge/__pycache__/cli.cpython-313.pyc b/src/forge/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..047e443e20e56a7752e5b849112320ccad2e34b4 GIT binary patch literal 27871 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 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 0000000000000000000000000000000000000000..432987aaafe81849889a343446c4fa3623a2ff2d GIT binary patch literal 11401 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 literal 0 HcmV?d00001 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" }, +]