Skip to main content

A Python library for theming facilities

Project description

Armonia

ἁρμονία

Ancient Greek; noun

The principle of order and beauty through balanced relationships.

A joining, joint; harmony, agreement, concord.

A Python library for elegant theme management with dynamic computed colors, font definitions, and powerful transformation functions.

Python Version License: MIT

Overview

Armonia provides a sophisticated yet simple system for managing color and font themes in Python applications.

Built on top of polychromos, it offers:

  • Centralized Theme Management: Define all your colors and fonts in one place
  • Dynamic Computed Colors: Colors that automatically update when base colors change
  • Dynamic Computed Fonts: Fonts derived from other fonts (varying size, weight, and italic style)
  • Color Functions: Comprehensive color transformation library (lighter, darker, mix, contrast, etc.)
  • Font Functions: Font transformation library (scale_size, bolder, lighter, italic, roman, etc.)
  • Palette System: Organize colors into named collections
  • Logotype Management: Store and manage logo URIs alongside your theme
  • CSS Export: Generate CSS custom properties (colors) and classes (fonts) directly from the theme
  • Serialization Support: Save and load themes from JSON/YAML
  • Safety First: Recursion protection and conflict detection for both colors and fonts
  • Type-Safe: Full type hints and mypy compatibility

Installation

pip install armonia

Or using uv:

uv add armonia

Quick Start

from armonia import Theme
from armonia import colorfunctions as cf
from polychromos.color import HSLColor

# Create a theme
theme = Theme()

# Set base colors
theme.set_color("primary", HSLColor.from_hex("#2563eb"))
theme.set_color("background", HSLColor.from_hex("#ffffff"))
theme.set_color("text", HSLColor.from_hex("#1f2937"))

# Create computed colors that automatically derive from base colors
theme.set_computed_color("primary_light", cf.lighter("primary", 0.2))
theme.set_computed_color("primary_dark", cf.darker("primary", 0.2))
theme.set_computed_color("text_muted", cf.mix("text", "background", 0.4))
theme.set_computed_color("on_primary", cf.contrast("primary"))

# Get colors - they resolve automatically
print(theme.get_color("primary_light").to_css_hex())  # #6b95f1

# Change the base color - computed colors update automatically!
theme.set_color("primary", HSLColor.from_hex("#7c3aed"))
print(theme.get_color("primary_light").to_css_hex())  # Now reflects new primary color

Key Features

1. Dynamic Color Computation

Computed colors are functions that derive their values from other colors in the theme. When you change a base color, all computed colors update automatically:

# Define once
theme.set_color("brand", HSLColor.from_hex("#ff6b6b"))
theme.set_computed_color("brand_hover", cf.darker("brand", 0.1))
theme.set_computed_color("brand_subtle", cf.alpha("brand", 0.3))

# Update anywhere - computed colors follow
theme.set_color("brand", HSLColor.from_hex("#4ecdc4"))
# brand_hover and brand_subtle automatically reflect the new brand color

2. Comprehensive Color Functions

Over 40 built-in transformation functions organized by purpose:

Lightness & Darkness

cf.lighter("color", 0.2)      # Increase lightness
cf.darker("color", 0.2)       # Decrease lightness
cf.brighten("color", 0.3)     # Significantly lighter
cf.dim("color", 0.3)          # Significantly darker

Saturation

cf.saturate("color", 0.2)     # More vivid
cf.desaturate("color", 0.2)   # More gray
cf.muted("color", 0.4)        # Significantly less saturated
cf.grayscale("color")         # Complete desaturation

Mixing & Blending

cf.mix("color1", "color2", 0.5)           # Blend two colors
cf.tint("color", 0.2)                     # Mix with white
cf.shade("color", 0.2)                    # Mix with black
cf.tone("color", 0.2)                     # Mix with gray
cf.softer("color", "background", 0.3)     # Shift toward background

Opacity

cf.alpha("color", 0.5)        # Set opacity
cf.fade("color", 0.3)         # Fade (alias for alpha)

Advanced Transformations

cf.rotate_hue("color", 180)                # Rotate hue by degrees
cf.complementary("color")                  # Opposite on color wheel
cf.invert("color")                         # Invert lightness
cf.contrast("color")                       # Choose black or white for contrast
cf.alias("color")                          # Reference another color

Scaling Functions

# Multiply mode (direct multiplication)
cf.scale_lightness("color", 0.7)          # new = old * 0.7
cf.scale_saturation("color", 0.5)         # new = old * 0.5

# Screen mode (gentler, preserves more original color)
cf.screen_lightness("color", 0.6)         # new = 1 - (1-old) * 0.6
cf.screen_saturation("color", 0.4)        # new = 1 - (1-old) * 0.4

Multi-Step Transformations

# Chain multiple transformations
theme.set_computed_color(
    "accent_complex",
    cf.multi(
        "accent",
        lambda c: c.delta(0, 0, 0.1),    # Lighter
        lambda c: c.delta(0, -0.2, 0),   # Less saturated
    )
)

3. Color Palettes

Organize related colors into named palettes:

# Define a palette
theme.set_palette("primary_scale", [
    "primary_dark",
    "primary",
    "primary_light",
    "primary_bright"
])

# Get all colors in the palette
colors = theme.get_palette("primary_scale")  # Returns List[HSLColor]

# Use for UI scales, gradients, etc.
theme.set_palette("grays", ["gray_900", "gray_700", "gray_500", "gray_300", "gray_100"])

4. Logotype Management

Store and manage logotype URIs in your theme alongside colors:

# Add logotypes with different URI schemes
theme.set_logotype("company_logo", "https://example.com/logo.svg")
theme.set_logotype("dark_logo", "https://example.com/logo-dark.svg")
theme.set_logotype("favicon", "https://example.com/favicon.ico")

# File URIs for local assets
theme.set_logotype("local_logo", "file:///path/to/logo.png")

# Data URIs for inline resources
theme.set_logotype("inline_icon", "data:image/svg+xml,<svg>...</svg>")

# Retrieve logotype URIs
logo_uri = theme.get_logotype("company_logo")  # Returns the URI string
print(logo_uri)  # https://example.com/logo.svg

# Remove logotypes
theme.remove_logotype("old_logo")

# URI validation
theme.set_logotype("invalid", "not-a-uri")  # Raises InvalidURIError

Logotypes accept any valid URI scheme including https://, http://, file://, data:, ftp://, and others. URIs are validated to ensure they follow standard format.

5. Font Management

Define user fonts and computed fonts that derive from them:

from armonia import Theme, typography
from armonia import fontfunctions as ff

theme = Theme()

# Define base fonts
theme.set_font("body", typography.Font("Inter", 16.0, 400, False))
theme.set_font("heading", typography.Font("Georgia", 32.0, 700, False))

# Derive computed fonts — only size, weight, and italic can vary; family is always inherited
theme.set_computed_font("body_bold", ff.bolder("body", 300))       # weight 400 -> 700
theme.set_computed_font("body_italic", ff.italic("body"))           # italic enabled
theme.set_computed_font("body_small", ff.adjust_size("body", -4.0)) # 16px -> 12px
theme.set_computed_font("body_large", ff.scale_size("body", 1.5))   # 16px -> 24px
theme.set_computed_font("caption", ff.adjust("body", size_delta=-3.0, weight_delta=-100))

# Retrieve fonts
font = theme.get_font("body_bold")
print(font.family)   # "Inter"
print(font.size)     # 16.0
print(font.weight)   # 700
print(font.italic)   # False

# Get all fonts sorted by size
for entry in theme.get_all_fonts(sort_by="size"):
    style = "italic" if entry.font.italic else "roman"
    print(f"{entry.name}: {entry.font.family} {entry.font.size}px w{entry.font.weight} {style} ({entry.source})")

Font Functions

Function Description
ff.scale_size(key, factor) Multiply size by factor (must be > 0)
ff.adjust_size(key, delta) Add delta pixels to size
ff.bolder(key, amount=100) Increase weight, clamped to 1000
ff.lighter(key, amount=100) Decrease weight, clamped to 1
ff.italic(key) Enable italic style
ff.roman(key) Disable italic style (upright)
ff.adjust(key, size_delta, weight_delta, make_italic) Adjust multiple properties at once
ff.alias(key) Reference another font by name

Computed fonts can also be chained — a computed font can derive from another computed font:

theme.set_computed_font("body_bold", ff.bolder("body", 300))
theme.set_computed_font("body_bold_italic", ff.italic("body_bold"))

6. Get All Colors

Retrieve and sort all theme colors:

# Get all colors sorted by name (alphabetically)
colors = theme.get_all_colors(sort_by="name")

# Sort by hue (color wheel order)
colors = theme.get_all_colors(sort_by="hue")

# Sort by saturation (most vivid to most muted)
colors = theme.get_all_colors(sort_by="saturation", reverse=True)

# Sort by lightness (dark to light)
colors = theme.get_all_colors(sort_by="lightness")

# Each entry includes name, color, and source
for entry in colors:
    print(f"{entry.name}: {entry.color.to_css_hex()} ({entry.source})")
    # Example: "primary: #2563eb (manual)"
    # Example: "primary_light: #6b95f1 (computed)"

7. Flexible Color Resolution

The get_color() method resolves colors with a smart fallback chain:

# 1. Theme colors (highest priority)
theme.set_color("brand", HSLColor.from_hex("#ff6b6b"))
theme.get_color("brand")

# 2. Computed colors
theme.set_computed_color("brand_light", cf.lighter("brand"))
theme.get_color("brand_light")

# 3. Web colors (CSS/HTML standard colors)
theme.get_color("tomato")      # Falls back to web color
theme.get_color("skyblue")

# 4. Hex colors (direct parsing)
theme.get_color("#3498db")     # Parses as hex

8. Serialization Support

Load themes from dictionaries (compatible with JSON/YAML):

# Export theme to dictionary
theme_dict = {
    "colors": {
        "primary": "#2563eb",
        "secondary": "#7c3aed"
    },
    "computed_colors": {
        "primary_light": {
            "function": "lighter",
            "args": ["primary", 0.15]
        }
    },
    "palettes": {
        "primary_scale": ["primary_dark", "primary", "primary_light"]
    },
    "logotypes": {
        "company_logo": "https://example.com/logo.svg",
        "favicon": "https://example.com/favicon.ico"
    },
    "fonts": {
        "body": {"family": "Inter", "size": 16, "weight": 400, "italic": False},
        "heading": {"family": "Georgia", "size": 32, "weight": 700, "italic": False}
    },
    "computed_fonts": {
        "body_bold": {
            "function": "bolder",
            "args": ["body", 300]
        },
        "body_italic": {
            "function": "italic",
            "args": ["body"]
        },
        "caption": {
            "function": "adjust",
            "args": {"font_key": "body", "size_delta": -4.0, "weight_delta": -100}
        }
    }
}

# Load from dictionary
theme = Theme.from_dict(theme_dict)

# Or load from JSON
import json

with open("theme.json", "r") as f:
    theme = Theme.from_dict(json.load(f))

# Access fonts
body_bold = theme.get_font("body_bold")
logo_uri = theme.get_logotype("company_logo")

9. CSS Export

Export theme colors as CSS custom property declarations and fonts as CSS classes.

Colors

to_css_colors(prefix="--color-") returns bare declarations — no wrapping block — so you can embed them in any CSS rule (:root, .dark, a media query, etc.):

theme.set_color("primary", HSLColor.from_hex("#2563eb"))
theme.set_color("accent", HSLColor.from_hex("#f59e0b"))
theme.set_computed_color("primary_dark", cf.darker("primary", 0.15))

print(":root {")
print(theme.to_css_colors())
print("}")
# :root {
# --color-accent: hsl(45deg 96% 53%);
# --color-primary: hsl(221deg 83% 53%);
# --color-primary_dark: hsl(221deg 83% 38%);
# }

# Custom prefix
print(theme.to_css_colors(prefix="--brand-"))
# --brand-accent: hsl(45deg 96% 53%);
# --brand-primary: hsl(221deg 83% 53%);
# --brand-primary_dark: hsl(221deg 83% 38%);

Colors with opacity < 1 automatically use the / alpha syntax: hsl(0deg 100% 50% / 50%).

Fonts

to_css_fonts(prefix="font-", size_unit="px") returns one CSS class block per font, separated by blank lines:

theme.set_font("body", typography.Font("Inter", 16.0, 400, False))
theme.set_font("heading", typography.Font("Georgia", 32.0, 700, False))
theme.set_computed_font("body_bold", ff.bolder("body", 300))

print(theme.to_css_fonts())
# .font-body {
#   font-family: "Inter";
#   font-size: 16px;
#   font-weight: 400;
#   font-style: normal;
# }
#
# .font-body_bold {
#   font-family: "Inter";
#   font-size: 16px;
#   font-weight: 700;
#   font-style: normal;
# }
#
# .font-heading {
#   font-family: "Georgia";
#   font-size: 32px;
#   font-weight: 700;
#   font-style: normal;
# }

# Custom prefix and unit
print(theme.to_css_fonts(prefix="text-", size_unit="rem"))
# .text-body { font-size: 16rem; ... }

10. Safety & Validation

Armonia protects against common errors:

# Prevents color naming conflicts
theme.set_color("red", ...)               # Error: 'red' is a web color
theme.set_color("primary", ...)
theme.set_computed_color("primary", ...)  # Error: already exists as manual color

# Detects circular color dependencies
theme.set_computed_color("a", cf.lighter("b"))
theme.set_computed_color("b", cf.darker("a"))
theme.get_color("a")  # Raises ColorRecursionError

# Prevents font naming conflicts
theme.set_font("body", ...)
theme.set_computed_font("body", ...)  # Error: already exists as manual font

# Detects circular font dependencies
theme.set_computed_font("x", ff.bolder("y"))
theme.set_computed_font("y", ff.lighter("x"))
theme.get_font("x")  # Raises FontRecursionError

# Clear error messages
theme.get_color("nonexistent")  # ColorNotFoundError with helpful message
theme.get_font("nonexistent")   # FontNotFoundError with helpful message

Complete Example

Here's a complete example of a modern design system:

from armonia import Theme, typography
from armonia import colorfunctions as cf
from armonia import fontfunctions as ff
from polychromos.color import HSLColor

# Create theme
theme = Theme()

# Base colors
theme.set_color("primary", HSLColor.from_hex("#2563eb"))
theme.set_color("secondary", HSLColor.from_hex("#7c3aed"))
theme.set_color("accent", HSLColor.from_hex("#f59e0b"))
theme.set_color("background", HSLColor.from_hex("#ffffff"))
theme.set_color("surface", HSLColor.from_hex("#f3f4f6"))
theme.set_color("text", HSLColor.from_hex("#1f2937"))

# Primary variations
theme.set_computed_color("primary_light", cf.lighter("primary", 0.15))
theme.set_computed_color("primary_dark", cf.darker("primary", 0.15))
theme.set_computed_color("primary_muted", cf.muted("primary", 0.4))
theme.set_computed_color("primary_vivid", cf.saturate("primary", 0.2))

# Semantic colors
theme.set_computed_color("on_primary", cf.contrast("primary"))
theme.set_computed_color("on_secondary", cf.contrast("secondary"))
theme.set_computed_color("on_accent", cf.contrast("accent"))

# Surface variations
theme.set_computed_color("surface_light", cf.lighter("surface", 0.05))
theme.set_computed_color("surface_dark", cf.darker("surface", 0.05))

# Text hierarchy
theme.set_computed_color("text_muted", cf.mix("text", "background", 0.4))
theme.set_computed_color("text_subtle", cf.alpha("text", 0.6))

# Define palettes
theme.set_palette("primary_scale", [
    "primary_dark",
    "primary",
    "primary_light",
    "primary_vivid"
])

theme.set_palette("surfaces", [
    "background",
    "surface",
    "surface_light",
    "surface_dark"
])

theme.set_palette("text_hierarchy", [
    "text",
    "text_muted",
    "text_subtle"
])

# Add logotypes
theme.set_logotype("company_logo", "https://example.com/logo.svg")
theme.set_logotype("dark_logo", "https://example.com/logo-dark.svg")
theme.set_logotype("favicon", "https://example.com/favicon.ico")

# Base fonts
theme.set_font("body", typography.Font("Inter", 16.0, 400, False))
theme.set_font("heading", typography.Font("Georgia", 32.0, 700, False))
theme.set_font("mono", typography.Font("JetBrains Mono", 14.0, 400, False))

# Computed fonts
theme.set_computed_font("body_bold", ff.bolder("body", 300))
theme.set_computed_font("body_italic", ff.italic("body"))
theme.set_computed_font("body_small", ff.adjust_size("body", -4.0))
theme.set_computed_font("caption", ff.adjust("body", size_delta=-3.0, weight_delta=-100))
theme.set_computed_font("heading_italic", ff.italic("heading"))

# Use colors
print(f"Primary: {theme.get_color('primary').to_css_hex()}")
print(f"Primary Light: {theme.get_color('primary_light').to_css_hex()}")
print(f"Text on Primary: {theme.get_color('on_primary').to_css_hex()}")

# Get a palette
primary_colors = theme.get_palette("primary_scale")
for i, color in enumerate(primary_colors):
    print(f"  {i}: {color.to_css_hex()}")

# Get logotypes
print(f"Logo: {theme.get_logotype('company_logo')}")

# Get fonts
body_bold = theme.get_font("body_bold")
print(f"Body Bold: {body_bold.family} {body_bold.size}px w{body_bold.weight}")

Integration Examples

Flask/Django (CSS Custom Properties)

from armonia import Theme
from polychromos.color import HSLColor

theme = Theme()
# ... setup theme ...

def generate_css():
    """Generate CSS custom properties and font classes from theme."""
    return "\n".join([
        ":root {",
        theme.to_css_colors(),
        "}",
        "",
        theme.to_css_fonts(),
    ])

# In your Flask/Django view
@app.route('/theme.css')
def theme_css():
    return generate_css(), 200, {'Content-Type': 'text/css'}

TailwindCSS Integration

import json

def generate_tailwind_colors(theme):
    """Generate Tailwind color configuration."""
    colors = {}
    for entry in theme.get_all_colors():
        colors[entry.name] = entry.color.to_css_hex()

    config = {
        "theme": {
            "extend": {
                "colors": colors
            }
        }
    }

    with open("tailwind.theme.json", "w") as f:
        json.dump(config, f, indent=2)

Terminal/CLI Applications

from armonia import Theme
from polychromos.color import HSLColor

theme = Theme()
# ... setup theme ...

# Get ANSI color codes for terminal output using polychromos
def color_text(text, color_name):
    color = theme.get_color(color_name)
    # Use 24-bit true color ANSI codes for best color accuracy
    ansi_code = color.to_ansi_color(foreground=True, bits=24)
    return f"{ansi_code}{text}\033[0m"

# For backgrounds
def color_background(text, bg_color_name, fg_color_name="white"):
    bg_color = theme.get_color(bg_color_name)
    fg_color = theme.get_color(fg_color_name)
    bg_ansi = bg_color.to_ansi_color(foreground=False, bits=24)
    fg_ansi = fg_color.to_ansi_color(foreground=True, bits=24)
    return f"{bg_ansi}{fg_ansi}{text}\033[0m"

print(color_text("Success!", "success"))
print(color_text("Warning!", "warning"))
print(color_background(" INFO ", "primary", "white"))

Live Example

Check out the interactive example in examples/color-showcase.py:

cd examples
uv run color-showcase.py

Then open http://localhost:5000 to see a live demonstration with:

  • Interactive color pickers
  • Real-time computed color updates
  • Light/dark theme switching
  • All colors panel with sorting options

Development

This project uses uv for dependency management.

Setup

# Clone the repository
git clone https://gitlab.com/Kencho1/armonia.git
cd armonia

# Install dependencies
uv sync

# Install with test dependencies
uv sync --extra test

Testing

# Run tests
uv run pytest

# Run with coverage
uv run pytest --cov=armonia

# Type checking
uv run mypy armonia

# Linting
uv run ruff check armonia

License

MIT License - see LICENSE file for details.

Links

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

armonia-1.2.0.tar.gz (36.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

armonia-1.2.0-py3-none-any.whl (22.8 kB view details)

Uploaded Python 3

File details

Details for the file armonia-1.2.0.tar.gz.

File metadata

  • Download URL: armonia-1.2.0.tar.gz
  • Upload date:
  • Size: 36.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for armonia-1.2.0.tar.gz
Algorithm Hash digest
SHA256 d9a606dc4e55c71c0789d08c790555e3ee61ecb30cd7c0b206904f4b986154cb
MD5 6e2a836a8a4228d0321de04c578d5b50
BLAKE2b-256 df0455793e7fb3f351ab964af489766dc8e693591646f830b37a4feeaf7998b2

See more details on using hashes here.

File details

Details for the file armonia-1.2.0-py3-none-any.whl.

File metadata

  • Download URL: armonia-1.2.0-py3-none-any.whl
  • Upload date:
  • Size: 22.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for armonia-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 43bdeb3134f46acfbb69f700a6639580ac1fed31460c7c2a08ce52b2928bf4f4
MD5 acbff7abfa80ddb2535c1d3a902206c7
BLAKE2b-256 2b9b47d287079b988dfe00a2f477d1d087d51cff437fe77cae835a220cc9246d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page