Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.1] - 2026-03-09

### Added

- Tests for `load_from_path`, `save_to_path`, and `parse_scheme_input` config helpers
- Tests for `remove_config_at` covering both existing and non-existent directories
- Smoke test for `print_color_scheme_preview`
- README documentation for color schemes: options table entry, dedicated section with all 5 schemes, first-run prompt
note, and uninstall clarification

### Changed

- Extracted `load_from_path`, `save_to_path`, and `parse_scheme_input` as testable helpers in `config.rs`
- Extracted `remove_config_at` as a testable helper in `uninstall.rs`

## [1.1.0] - 2026-03-09

### Added

- Five built-in color schemes for table output, selectable and persisted per user:
- `default` - GitHub-style SemVer severity (`#D73A49` / `#0366D6` / `#28A745`)
- `okabe-ito` - Color-blind safe OkabeIto palette (`#E69F00` / `#0072B2` / `#009E73`)
- `traffic-light` - Classic red/yellow/green (`#E74C3C` / `#F1C40F` / `#2ECC71`)
- `severity` - Monitoring/observability style (`#8E44AD` / `#3498DB` / `#95A5A6`)
- `high-contrast` - Maximum distinction, color-blind safe (`#CC79A7` / `#0072B2` / `#F0E442`)
- `default` - GitHub-style SemVer severity (`#D73A49` / `#0366D6` / `#28A745`)
- `okabe-ito` - Color-blind safe Okabe-Ito palette (`#E69F00` / `#0072B2` / `#009E73`)
- `traffic-light` - Classic red/yellow/green (`#E74C3C` / `#F1C40F` / `#2ECC71`)
- `severity` - Monitoring/observability style (`#8E44AD` / `#3498DB` / `#95A5A6`)
- `high-contrast` - Maximum distinction, color-blind safe (`#CC79A7` / `#0072B2` / `#F0E442`)
- All colors rendered with true-color (24-bit) escape codes for exact hex fidelity
- `--set-color-scheme` flag: run without a value to preview all schemes visually, or pass a scheme name to save it permanently
- Color scheme preference persisted to `~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (Windows)
- `--set-color-scheme` flag: run without a value to preview all schemes visually, or pass a scheme name to save it
permanently
- Color scheme preference persisted to `~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (
Windows)
- First-run interactive prompt to choose a color scheme on initial install
- `--uninstall` now also removes the `pycu/` config directory

Expand All @@ -42,7 +59,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Progress bar during PyPI lookups
- Install scripts for Linux/macOS (`install.sh`) and Windows (`install.ps1`)

[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.1.0...HEAD
[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.1.1...HEAD

[1.1.1]: https://github.com/Logic-py/python-check-updates/compare/1.1.0...1.1.1

[1.1.0]: https://github.com/Logic-py/python-check-updates/compare/1.0.0...1.1.0

[1.0.0]: https://github.com/Logic-py/python-check-updates/releases/tag/1.0.0
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pycu"
version = "1.1.0"
version = "1.1.1"
edition = "2024"
rust-version = "1.85"
description = "Check your Python dependencies for newer versions on PyPI"
Expand Down
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ So I built pycu.
- Filter by bump level: major, minor, or patch only
- JSON output for scripting
- Self-updates via `--self-update`
- Color-coded output showing exactly which version component changed
- Color-coded output showing exactly which version component changed, with 5 built-in color schemes including
color-blind safe options
- SHA-256 verified self-update downloads

## Installation
Expand Down Expand Up @@ -76,6 +77,8 @@ cargo install --git https://github.com/Logic-py/python-check-updates
pycu --uninstall
```

This removes the `pycu` binary and its configuration directory.

## Usage

Run in a directory that contains a `pyproject.toml` or `requirements.txt`:
Expand All @@ -92,16 +95,17 @@ pycu --file path/to/requirements.txt

### Options

| Flag | Short | Description |
|---------------------|-------|------------------------------------------------------------------|
| `--file <PATH>` | | Path to the dependency file (auto-detected if omitted) |
| `--upgrade` | `-u` | Rewrite the file in-place with updated constraints |
| `--target <LEVEL>` | `-t` | Only show `major`, `minor`, or `patch` bumps (default: `latest`) |
| `--json` | | Output results as JSON |
| `--concurrency <N>` | | Max concurrent PyPI requests (default: `10`) |
| `--self-update` | | Update pycu itself to the latest release |
| `--uninstall` | | Remove pycu from your system |
| `--version` | | Print the version |
| Flag | Short | Description |
|-------------------------------|-------|------------------------------------------------------------------|
| `--file <PATH>` | | Path to the dependency file (auto-detected if omitted) |
| `--upgrade` | `-u` | Rewrite the file in-place with updated constraints |
| `--target <LEVEL>` | `-t` | Only show `major`, `minor`, or `patch` bumps (default: `latest`) |
| `--json` | | Output results as JSON |
| `--concurrency <N>` | | Max concurrent PyPI requests (default: `10`) |
| `--set-color-scheme [SCHEME]` | | Preview all color schemes, or save one persistently |
| `--self-update` | | Update pycu itself to the latest release |
| `--uninstall` | | Remove pycu from your system |
| `--version` | | Print the version |

### Examples

Expand All @@ -122,6 +126,30 @@ pycu --file requirements-dev.txt
pycu --json
```

### Color schemes

pycu ships with five built-in color schemes. Your preference is saved to
`~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (Windows) and
applied automatically on every run.

**Preview all schemes** (shown with live colored examples in your terminal):

```sh
pycu --set-color-scheme
```

**Set a scheme:**

```sh
pycu --set-color-scheme default # red / blue / green - GitHub-style SemVer severity
pycu --set-color-scheme okabe-ito # orange / blue / teal - color-blind safe (Okabe-Ito)
pycu --set-color-scheme traffic-light # red / yellow / green - classic CI model
pycu --set-color-scheme severity # purple / blue / gray - monitoring/observability style
pycu --set-color-scheme high-contrast # magenta / blue / yellow - maximum distinction
```

On first run, pycu will show the preview and prompt you to choose a scheme interactively.

### JSON output

```json
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub enum TargetLevel {
pub enum ColorScheme {
/// #D73A49 / #0366D6 / #28A745 - GitHub-style SemVer severity (default)
Default,
/// #E69F00 / #0072B2 / #009E73 - OkabeIto, color-blind safe
/// #E69F00 / #0072B2 / #009E73 - Okabe-Ito, color-blind safe
OkabeIto,
/// #E74C3C / #F1C40F / #2ECC71 - traffic-light (red/yellow/green)
TrafficLight,
Expand Down
125 changes: 91 additions & 34 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -35,11 +35,7 @@ pub fn load() -> Result<Option<Config>> {
return Ok(None);
}

let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(Some(config))
load_from_path(&path).map(Some)
}

pub fn save(config: &Config) -> Result<()> {
Expand All @@ -48,15 +44,7 @@ pub fn save(config: &Config) -> Result<()> {
None => anyhow::bail!("Could not determine config directory"),
};

if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}

let contents = toml::to_string(config).context("Failed to serialize config")?;
fs::write(&path, contents)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
save_to_path(config, &path)
}

/// Interactive first-run prompt. Shows the color scheme preview, asks the user to pick,
Expand All @@ -71,16 +59,8 @@ pub fn first_run_setup() -> Result<Config> {

let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();

let color_scheme = match input.as_str() {
"okabe-ito" => ColorScheme::OkabeIto,
"traffic-light" => ColorScheme::TrafficLight,
"severity" => ColorScheme::Severity,
"high-contrast" => ColorScheme::HighContrast,
_ => ColorScheme::Default,
};

let color_scheme = parse_scheme_input(&input);
let config = Config { color_scheme };
save(&config)?;

Expand All @@ -92,6 +72,36 @@ pub fn first_run_setup() -> Result<Config> {
Ok(config)
}

// ── Internal helpers (pub(crate) for testing) ────────────────────────────────

pub(crate) fn load_from_path(path: &Path) -> Result<Config> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))
}

pub(crate) fn save_to_path(config: &Config, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let contents = toml::to_string(config).context("Failed to serialize config")?;
fs::write(path, contents)
.with_context(|| format!("Failed to write config file: {}", path.display()))
}

/// Parse a raw user input string into a `ColorScheme`, defaulting to `Default`.
pub(crate) fn parse_scheme_input(input: &str) -> ColorScheme {
match input.trim().to_lowercase().as_str() {
"okabe-ito" => ColorScheme::OkabeIto,
"traffic-light" => ColorScheme::TrafficLight,
"severity" => ColorScheme::Severity,
"high-contrast" => ColorScheme::HighContrast,
_ => ColorScheme::Default,
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -131,23 +141,70 @@ mod tests {
}

#[test]
fn test_nonexistent_path_has_no_file() {
let fake_path = PathBuf::from("/nonexistent/pycu/config.toml");
assert!(!fake_path.exists());
fn test_save_to_path_creates_dirs_and_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested").join("pycu").join("config.toml");
let cfg = Config {
color_scheme: ColorScheme::Severity,
};
save_to_path(&cfg, &path).unwrap();
assert!(path.exists());
}

#[test]
fn test_save_and_load_roundtrip() {
fn test_load_from_path_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");

let cfg = Config {
color_scheme: ColorScheme::OkabeIto,
color_scheme: ColorScheme::TrafficLight,
};
let contents = toml::to_string(&cfg).unwrap();
std::fs::write(&path, contents).unwrap();
save_to_path(&cfg, &path).unwrap();
let back = load_from_path(&path).unwrap();
assert_eq!(back.color_scheme, ColorScheme::TrafficLight);
}

let back: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(back.color_scheme, ColorScheme::OkabeIto);
#[test]
fn test_load_from_path_invalid_toml_returns_err() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "this is not valid toml !!!").unwrap();
let result = load_from_path(&path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to parse"));
}

#[test]
fn test_load_from_path_missing_file_returns_err() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nonexistent.toml");
let result = load_from_path(&path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to read"));
}

#[test]
fn test_parse_scheme_input_all_variants() {
assert_eq!(parse_scheme_input("okabe-ito"), ColorScheme::OkabeIto);
assert_eq!(
parse_scheme_input("traffic-light"),
ColorScheme::TrafficLight
);
assert_eq!(parse_scheme_input("severity"), ColorScheme::Severity);
assert_eq!(
parse_scheme_input("high-contrast"),
ColorScheme::HighContrast
);
assert_eq!(parse_scheme_input("default"), ColorScheme::Default);
assert_eq!(parse_scheme_input(""), ColorScheme::Default);
assert_eq!(parse_scheme_input("unknown"), ColorScheme::Default);
}

#[test]
fn test_parse_scheme_input_trims_whitespace_and_ignores_case() {
assert_eq!(parse_scheme_input(" OKABE-ITO\n"), ColorScheme::OkabeIto);
assert_eq!(
parse_scheme_input(" Traffic-Light "),
ColorScheme::TrafficLight
);
}
}
9 changes: 7 additions & 2 deletions src/output/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fn palette_for(scheme: &ColorScheme) -> Palette {
minor: (3, 102, 214), // #0366D6 blue
patch: (40, 167, 69), // #28A745 green
},
// OkabeIto color-blind safe
// Okabe-Ito color-blind safe
ColorScheme::OkabeIto => Palette {
major: (230, 159, 0), // #E69F00 orange
minor: (0, 114, 178), // #0072B2 blue
Expand Down Expand Up @@ -105,7 +105,7 @@ pub fn print_color_scheme_preview() {
(
"okabe-ito",
ColorScheme::OkabeIto,
"Color-blind safe - OkabeIto palette",
"Color-blind safe - Okabe-Ito palette",
["orange #E69F00", "blue #0072B2", "teal #009E73"],
),
(
Expand Down Expand Up @@ -251,6 +251,11 @@ mod tests {
}
}

#[test]
fn test_print_color_scheme_preview_does_not_panic() {
print_color_scheme_preview();
}

#[test]
fn test_print_table_empty() {
print_table(&[], false, &ColorScheme::Default);
Expand Down
Loading