This commit is contained in:
randogoth 2025-12-24 07:56:11 +02:00
parent 359c250101
commit da6d24c74f
13 changed files with 973 additions and 555 deletions

206
README.md
View file

@ -1,10 +1,10 @@
# Forge - A Home-Manager Like Tool for Fedora # 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 ## 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) - Managing COPR repositories (enable/disable automatically)
- Installing and managing system packages via dnf - Installing and managing system packages via dnf
- Managing dotfiles through symlinks to actual files - 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 ## Installation
1. Clone or download this repository 1. Clone or download this repository.
2. Make the script executable: 2. Install dependencies with `uv` (none beyond the standard library, but this sets up the venv):
```bash ```bash
chmod +x forge uv sync
``` ```
3. Optionally, move it to a directory in your PATH: 3. Run with `uv`:
```bash ```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 ## Quick Start
```bash ```bash
# Initialize the configuration structure # Initialize the configuration structure
./forge init uv run forge init
# Edit the configuration file to add packages and dotfiles # Edit the configuration file to add packages and dotfiles
nano ~/.config/forge/forge.toml nano ~/.config/forge/forge.toml
# Apply configuration # Apply configuration
./forge switch uv run forge switch
# Check status # Check status
./forge status uv run forge status
``` ```
You can swap `uv run forge ...` for `./forge ...` if you prefer the local shim.
## Commands ## Commands
### `init` ### `init`
Initialize the configuration structure and create `forge.toml`. Initialize the configuration structure and create `forge.toml`.
```bash ```bash
./forge init uv run forge init
# or ./forge init
``` ```
Creates: Creates:
@ -57,7 +68,8 @@ Creates:
Apply the current configuration (enable COPR, install packages, deploy dotfiles). Apply the current configuration (enable COPR, install packages, deploy dotfiles).
```bash ```bash
./forge switch uv run forge switch
# or ./forge switch
``` ```
This command: This command:
@ -72,7 +84,8 @@ This command:
Show current configuration status and information. Show current configuration status and information.
```bash ```bash
./forge status uv run forge status
# or ./forge status
``` ```
Displays: Displays:
@ -84,7 +97,8 @@ Displays:
Show help message with all available commands. Show help message with all available commands.
```bash ```bash
./forge help uv run forge help
# or ./forge help
``` ```
## Configuration ## Configuration
@ -96,23 +110,21 @@ The central configuration file located at `~/.config/forge/forge.toml`:
# Forge Configuration File # Forge Configuration File
# User-level configuration similar to home-manager/nixos # 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] [forge]
version = "1.0" version = "1.0"
last_switch = null last_switch = ""
[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
[dotfiles] [dotfiles]
# Dotfiles to manage with symlinks # Dotfiles to manage with symlinks
@ -135,30 +147,32 @@ backup_dir = "backup"
- `version`: Configuration file version - `version`: Configuration file version
- `last_switch`: Timestamp of last switch operation (auto-updated) - `last_switch`: Timestamp of last switch operation (auto-updated)
#### `[copr]` #### `copr`
List of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. Array of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable.
**Examples:** **Examples:**
```toml ```toml
[copr] copr = [
copr.fedorainfracloud.org/username/repository "copr.fedorainfracloud.org/username/repository",
copr.fedorainfracloud.org/anotheruser/anotherrepo "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. To remove a COPR repository, simply delete the line containing the repository name. Forge will automatically disable it during the next switch.
#### `[packages]` #### `packages`
List of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. Array of packages to install via dnf. **Just list package names** - presence means install, absence means don't install.
**Examples:** **Examples:**
```toml ```toml
[packages] packages = [
git "git",
vim "vim",
curl "curl",
wget "wget",
nodejs "nodejs",
npm "npm",
]
``` ```
To remove a package, simply delete the line containing the package name. To remove a package, simply delete the line containing the package name.
@ -191,17 +205,19 @@ Additional configuration options:
## How It Works ## How It Works
### COPR Repository Management ### COPR Repository Management
COPR repositories are managed through the `[copr]` section in `forge.toml`: COPR repositories are managed through the `copr` array in `forge.toml`:
- **Add COPR repo**: Simply add the repository name on a new line - **Add COPR repo**: Add the repository name to the array
- **Remove COPR repo**: Delete the line containing the repository name - **Remove COPR repo**: Remove the entry from the array
- **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration - **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration
- **No flags needed**: Just presence/absence of the repository name matters - **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 ### Package Management
Packages are managed through the `[packages]` section in `forge.toml`: Packages are managed through the `packages` array in `forge.toml`:
- **Add package**: Simply add the package name on a new line - **Add package**: Add the package name to the array
- **Remove package**: Delete the line containing the package name - **Remove package**: Remove the entry from the array
- **No flags needed**: Just presence/absence of the package name matters - **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 ### Dotfile Management
Forge uses symlinks to manage dotfiles: Forge uses symlinks to manage dotfiles:
@ -218,23 +234,29 @@ Before creating symlinks, Forge:
3. Removes the original file 3. Removes the original file
4. Creates the symlink to your managed dotfile 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 ## Examples
### Basic Setup ### Basic Setup
```bash ```bash
# Initialize forge # Initialize forge
./forge init uv run forge init
# Edit forge.toml to add COPR repos and packages # Edit forge.toml to add COPR repos and packages
nano ~/.config/forge/forge.toml nano ~/.config/forge/forge.toml
# Add to [copr] section: # Add to the arrays:
copr.fedorainfracloud.org/username/cool-repo # copr = [
# "copr.fedorainfracloud.org/username/cool-repo",
# Add to [packages] section: # ]
git # packages = [
vim # "git",
curl # "vim",
# "curl",
# ]
# Create your dotfiles directory and add files # Create your dotfiles directory and add files
mkdir -p ~/.config/forge/dotfiles mkdir -p ~/.config/forge/dotfiles
@ -244,7 +266,7 @@ echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc
".bashrc" = ".bashrc" ".bashrc" = ".bashrc"
# Apply configuration # Apply configuration
./forge switch uv run forge switch
``` ```
### Managing Application Configurations ### Managing Application Configurations
@ -260,37 +282,36 @@ nano ~/.config/forge/forge.toml
".config/alacritty/alacritty.yml" = "alacritty.yml" ".config/alacritty/alacritty.yml" = "alacritty.yml"
# Apply changes # Apply changes
./forge switch uv run forge switch
``` ```
### COPR Repository Management Examples ### COPR Repository Management Examples
```toml ```toml
[copr] copr = [
# Development tools COPR # Development tools COPR
copr.fedorainfracloud.org/development/tools "copr.fedorainfracloud.org/development/tools",
copr.fedorainfracloud.org/user/neovim-nightly "copr.fedorainfracloud.org/user/neovim-nightly",
]
# To remove a COPR repo, just delete the line # To remove a COPR repo, just delete the entry
# Forge will automatically disable it during the next switch # Forge will automatically disable it during the next switch
``` ```
### Package Management Examples ### Package Management Examples
```toml ```toml
[packages] packages = [
# Development tools # Development tools
git "git",
vim "vim",
nodejs "nodejs",
npm "npm",
# System utilities # System utilities
curl "curl",
wget "wget",
tree "tree",
htop "htop",
]
# To remove a package, just delete the line # To remove a package, just delete the entry
# For example, to remove htop, delete the "htop" line
``` ```
### Version Control Your Configuration ### Version Control Your Configuration
@ -312,30 +333,35 @@ git commit -m "Updated vim configuration"
## Dependencies ## Dependencies
- Python 3.11+
- `tomlkit` (installed automatically via `uv sync`)
- `uv` for environment and script management
- `dnf` - Fedora package manager - `dnf` - Fedora package manager
- `dnf-plugins-core` - For COPR repository management - `dnf-plugins-core` - For COPR repository management
- `sed` - For updating configuration file (usually pre-installed)
## Migration from Previous Version ## 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 1. Your existing configuration will not be automatically migrated, but the CLI will still read the legacy format.
2. Run `./forge init` to create the new `forge.toml` structure 2. Run `uv run forge init` (or `./forge init`) to create the new `forge.toml` structure.
3. Convert your package configuration from: 3. Convert to arrays:
```toml ```toml
# Old format # Old formats
[packages]
git = true git = true
vim = true vim = true
``` # or
to: [packages]
```toml
# New format
git git
vim 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/` 4. Move your existing dotfiles from the old directory to `~/.config/forge/dotfiles/`
## License ## License
This project is open source. Feel free to contribute or report issues. This project is open source. Feel free to contribute or report issues.

482
forge
View file

@ -1,472 +1,24 @@
#!/bin/bash #!/usr/bin/env python3
from __future__ import annotations
# forge - A home-manager like script for Fedora import sys
# Provides dotfile management and package installation functionality from pathlib import Path
set -euo pipefail
# Configuration def main() -> None:
FORGE_DIR="${FORGE_DIR:-$HOME/.config/forge}" here = Path(__file__).resolve().parent
FORGE_TOML="$FORGE_DIR/forge.toml" src_dir = here / "src"
if src_dir.exists():
sys.path.insert(0, str(src_dir))
# Colors try:
RED='\033[0;31m' from forge.cli import main as forge_main
GREEN='\033[0;32m' except ImportError as exc: # pragma: no cover - fallback error path
YELLOW='\033[1;33m' sys.stderr.write(f"Failed to import forge CLI: {exc}\n")
BLUE='\033[0;34m' sys.exit(1)
NC='\033[0m' # No Color
# Logging functions forge_main()
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() { if __name__ == "__main__":
echo -e "${YELLOW}[WARNING]${NC} $1" main()
}
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 <<EOF
forge - A home-manager like script for Fedora
USAGE:
forge <COMMAND>
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 "$@"

20
pyproject.toml Normal file
View file

@ -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"]

1
src/forge/__init__.py Normal file
View file

@ -0,0 +1 @@
# Forge package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

625
src/forge/cli.py Normal file
View file

@ -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()

88
tests/test_config.py Normal file
View file

@ -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)

14
tmpcfg.toml Normal file
View file

@ -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"

92
uv.lock generated Normal file
View file

@ -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" },
]