diff --git a/.github/workflows/type.yaml b/.github/workflows/type.yaml index cd97e36..5656e65 100644 --- a/.github/workflows/type.yaml +++ b/.github/workflows/type.yaml @@ -6,7 +6,7 @@ on: pull_request: jobs: - check-formatting: + check-typing: runs-on: ubuntu-latest steps: @@ -19,7 +19,12 @@ jobs: enable-cache: true - name: Install the project - run: uv sync --all-groups + run: | + uv sync --all-groups + uv pip install brand_yml # for some reason I need to add this manually - - name: Run tests + - name: Run type check (ty) run: uv run ty check + + - name: Run type check (pyrefly) + run: uv run pyrefly check diff --git a/.gitignore b/.gitignore index 070c009..bf0322f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ sandbox* uv.lock scripts/release.sh +_brand.yml diff --git a/README.md b/README.md index 7440cf5..fefd26b 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,15 @@ function. ## Themes -**`morethemes`** offers 16 themes at the moment: +**`morethemes`** offers 16 built-in themes, as well as a special ‘brand’ +theme, based the [\_brand.yml +specification](https://posit-dev.github.io/brand-yml/): ### WSJ ``` python import morethemes as mt + mt.set_theme("wsj") ``` @@ -47,6 +50,7 @@ mt.set_theme("wsj") ``` python import morethemes as mt + mt.set_theme("urban") ``` @@ -56,6 +60,7 @@ mt.set_theme("urban") ``` python import morethemes as mt + mt.set_theme("minimal") ``` @@ -65,6 +70,7 @@ mt.set_theme("minimal") ``` python import morethemes as mt + mt.set_theme("ft") ``` @@ -74,6 +80,7 @@ mt.set_theme("ft") ``` python import morethemes as mt + mt.set_theme("nature") ``` @@ -83,6 +90,7 @@ mt.set_theme("nature") ``` python import morethemes as mt + mt.set_theme("economist") ``` @@ -92,6 +100,7 @@ mt.set_theme("economist") ``` python import morethemes as mt + mt.set_theme("greenwave") ``` @@ -101,6 +110,7 @@ mt.set_theme("greenwave") ``` python import morethemes as mt + mt.set_theme("lumen") ``` @@ -110,6 +120,7 @@ mt.set_theme("lumen") ``` python import morethemes as mt + mt.set_theme("ebonis") ``` @@ -119,6 +130,7 @@ mt.set_theme("ebonis") ``` python import morethemes as mt + mt.set_theme("lighter") ``` @@ -128,6 +140,7 @@ mt.set_theme("lighter") ``` python import morethemes as mt + mt.set_theme("vscode-dark") ``` @@ -137,6 +150,7 @@ mt.set_theme("vscode-dark") ``` python import morethemes as mt + mt.set_theme("nord") ``` @@ -146,6 +160,7 @@ mt.set_theme("nord") ``` python import morethemes as mt + mt.set_theme("retro") ``` @@ -155,6 +170,7 @@ mt.set_theme("retro") ``` python import morethemes as mt + mt.set_theme("darker") ``` @@ -164,6 +180,7 @@ mt.set_theme("darker") ``` python import morethemes as mt + mt.set_theme("yellowish") ``` @@ -173,7 +190,20 @@ mt.set_theme("yellowish") ``` python import morethemes as mt + mt.set_theme("monoblue") ``` [![](https://raw.githubusercontent.com/y-sunflower/morethemes/refs/heads/main/docs/img/monoblue.png)](https://y-sunflower.github.io/morethemes/) + +### BRAND + +The `brand` theme reads a Posit +[`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and +generates Matplotlib rcParams from its colors and typography. + +``` python +import morethemes as mt + +mt.set_theme("brand") +``` diff --git a/docs/.gitignore b/docs/.gitignore index 075b254..0e3521a 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1 +1,3 @@ /.quarto/ + +**/*.quarto_ipynb diff --git a/docs/README.qmd b/docs/README.qmd index b28ecd4..cc0d224 100644 --- a/docs/README.qmd +++ b/docs/README.qmd @@ -16,8 +16,6 @@ default-image-extension: "" [Documentation site](https://y-sunflower.github.io/morethemes/) - -
## Installation @@ -28,6 +26,7 @@ pip install morethemes Don't want to add **`morethemes`** as a dependency? You can either browse the [source code](https://github.com/y-sunflower/morethemes/blob/main/morethemes/themes.py) to find the rcParams, or use the `mt.get_rcparams("theme_name")` function. +
## Themes @@ -40,9 +39,12 @@ Don't want to add **`morethemes`** as a dependency? You can either browse the [s import morethemes as mt import matplotlib.pyplot as plt -print(f"**`morethemes`** offers {len(mt.ALL_THEMES)} themes at the moment:\n") +static_themes = [theme for theme in mt.ALL_THEMES if theme != "brand"] +print( + f"**`morethemes`** offers 16 built-in themes, as well as a special 'brand' theme, based the [_brand.yml specification](https://posit-dev.github.io/brand-yml/):\n" +) -for theme in mt.ALL_THEMES: +for theme in static_themes: print(f"### {theme.upper()}\n\n") print("```python") print("import morethemes as mt") @@ -55,4 +57,13 @@ for theme in mt.ALL_THEMES: f"[![](https://raw.githubusercontent.com/y-sunflower/morethemes/refs/heads/main/docs/img/{theme}.png)](https://y-sunflower.github.io/morethemes/)" ) print("\n") + +print("### BRAND\n\n") +print( + "The `brand` theme reads a Posit [`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and generates Matplotlib rcParams from its colors and typography.\n" +) +print("```python") +print("import morethemes as mt") +print('mt.set_theme("brand")') +print("```\n") ``` diff --git a/docs/contributing.md b/docs/contributing.md index 1f3fc0e..f23ae55 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -40,9 +40,9 @@ uv run pytest ```python sunglasses_theme = { - "name": "sunglasses", # the name of your theme + "name": "sunglasses", # the name of your theme "description": "A description of the theme", - "theme": { # all arguments passed to plt.rcParams + "theme": { # all arguments passed to plt.rcParams "axes.facecolor": "#232323", "figure.facecolor": "#282828", }, @@ -59,7 +59,7 @@ sunglasses_theme = { ALL_THEMES = { "darker": darker_theme, "lighter": lighter_theme, - "sunglasses": sunglasses_theme, # add your theme here + "sunglasses": sunglasses_theme, # add your theme here } ``` diff --git a/docs/guide/create-your-theme.md b/docs/guide/create-your-theme.md index f6f7359..2375e91 100644 --- a/docs/guide/create-your-theme.md +++ b/docs/guide/create-your-theme.md @@ -69,6 +69,7 @@ For example, when I set the theme to `darker`, it will call all parameters of th ```python import morethemes as mt + mt.set_theme("darker") ``` @@ -131,13 +132,13 @@ plt.rcParams.keys() Colors is the main thing that changes in a theme. Here you'll find every param name that you might need: ```python -plt.rcParams["axes.facecolor"] = "black" # Axes color -plt.rcParams["figure.facecolor"] = "black" # Figure color -plt.rcParams["axes.labelcolor"] = "grey" # Label color -plt.rcParams["axes.edgecolor"] = "grey" # Spines color -plt.rcParams["xtick.color"] = "grey" # x tick color -plt.rcParams["ytick.color"] = "grey" # y tick color -plt.rcParams["text.color"] = "white" # Text color +plt.rcParams["axes.facecolor"] = "black" # Axes color +plt.rcParams["figure.facecolor"] = "black" # Figure color +plt.rcParams["axes.labelcolor"] = "grey" # Label color +plt.rcParams["axes.edgecolor"] = "grey" # Spines color +plt.rcParams["xtick.color"] = "grey" # x tick color +plt.rcParams["ytick.color"] = "grey" # y tick color +plt.rcParams["text.color"] = "white" # Text color ``` By default, matplotlib charts will be **blue**. But if your chart has 2 colors, by default, the colors will be **blue** and **orange**. If there are 3, the colors will be **blue**, **orange** and **green**. This will continue with 7 other colors. @@ -146,8 +147,9 @@ This is called a `prop_cycle`. Changing this parameter will also to change defau ```python from cycler import cycler + plt.rcParams["axes.prop_cycle"] = cycler( - "color", ["#FED789", "#A4BED5", "#72874E", "#023743", "#476F84", "#453947"] + "color", ["#FED789", "#A4BED5", "#72874E", "#023743", "#476F84", "#453947"] ) ``` @@ -156,12 +158,12 @@ plt.rcParams["axes.prop_cycle"] = cycler( You can control the appearance of spines around matplotlib Axes with the following parameters: ```python -plt.rcParams["axes.spines.top"] = False # Remove top spine -plt.rcParams["axes.spines.bottom"] = False # Remove bottom spine -plt.rcParams["axes.spines.right"] = False # Remove right spine -plt.rcParams["axes.spines.left"] = False # Remove left spine -plt.rcParams["axes.edgecolor"] = "#eeeeee" # Spine color -plt.rcParams["axes.linewidth"] = 1.5 # Spine width +plt.rcParams["axes.spines.top"] = False # Remove top spine +plt.rcParams["axes.spines.bottom"] = False # Remove bottom spine +plt.rcParams["axes.spines.right"] = False # Remove right spine +plt.rcParams["axes.spines.left"] = False # Remove left spine +plt.rcParams["axes.edgecolor"] = "#eeeeee" # Spine color +plt.rcParams["axes.linewidth"] = 1.5 # Spine width ``` ### Fonts @@ -169,7 +171,7 @@ plt.rcParams["axes.linewidth"] = 1.5 # Spine width It is highly recommended to use a specific font for a theme, as it plays a major role in the brand image. ```python -plt.rcParams['font.family'] = _set_family("Font Name") +plt.rcParams["font.family"] = _set_family("Font Name") ``` ### Others diff --git a/docs/guide/reference.md b/docs/guide/reference.md index 03af7f4..858e2b0 100644 --- a/docs/guide/reference.md +++ b/docs/guide/reference.md @@ -6,7 +6,7 @@ This document explains everything you need to get started using **`morethemes`** `mt.set_theme()` is the **main function** you'll need. It basically has 2 arguments: -- `theme_name`: the name of the theme you want to apply. It must be one of the available themes: _"wsj"_, _"urban"_, _"minimal"_, _"ft"_, _"nature"_, _"economist"_, _"retro"_, _"yellowish"_, _"darker"_, _"monoblue"_. +- `theme_name`: the name of the theme you want to apply. It can be a built-in theme or `"brand"`. - `reset_to_default`: whether to reset to default matplotlib theme before applying the theme. If not, themes can stack on top of each other. Default is `True` (and recommended). Once the function is called, it will apply the theme to the **global matplotlib configuration**. You can reset to default theme by calling `mt.set_theme("default")` @@ -14,9 +14,27 @@ Once the function is called, it will apply the theme to the **global matplotlib ```python import morethemes as mt -mt.set_theme("wsj") # Wall Street Journal +mt.set_theme("wsj") # Wall Street Journal ``` +### The `brand` theme + +The `brand` theme reads a Posit [`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and generates Matplotlib rcParams from its colors and typography. Install the optional dependency first: + +```bash +pip install "morethemes[brand]" +``` + +Then call it from a project containing `_brand.yml` (or configure the path using the `brand_yml` package's supported discovery settings): + +```python +import morethemes as mt + +mt.set_theme("brand") +``` + +The generated theme uses the brand foreground/background colors, primary and secondary colors, palette colors for the plot color cycle, and base/heading typography settings supported by Matplotlib. System and Google fonts are supported; local-file and Bunny font sources are not. +
## `mt.get_rcparams()` @@ -29,6 +47,8 @@ import morethemes as mt wsj_rcparams = mt.get_rcparams("wsj") ``` +`mt.get_rcparams("brand")` dynamically parses the discovered `_brand.yml` file and returns the generated rcParams. + Then you can apply them to your matplotlib configuration with: ```python diff --git a/docs/img/darker.png b/docs/img/darker.png index 9baa960..0ca2808 100644 Binary files a/docs/img/darker.png and b/docs/img/darker.png differ diff --git a/docs/img/ebonis.png b/docs/img/ebonis.png index 50113bd..6071b82 100644 Binary files a/docs/img/ebonis.png and b/docs/img/ebonis.png differ diff --git a/docs/img/economist.png b/docs/img/economist.png index 083d633..820be0c 100644 Binary files a/docs/img/economist.png and b/docs/img/economist.png differ diff --git a/docs/img/ft.png b/docs/img/ft.png index 86d897b..03cdbb8 100644 Binary files a/docs/img/ft.png and b/docs/img/ft.png differ diff --git a/docs/img/greenwave.png b/docs/img/greenwave.png index 9d628ce..692e20e 100644 Binary files a/docs/img/greenwave.png and b/docs/img/greenwave.png differ diff --git a/docs/img/lighter.png b/docs/img/lighter.png index 8c617b8..40aa98b 100644 Binary files a/docs/img/lighter.png and b/docs/img/lighter.png differ diff --git a/docs/img/lumen.png b/docs/img/lumen.png index e37dbcb..ab20018 100644 Binary files a/docs/img/lumen.png and b/docs/img/lumen.png differ diff --git a/docs/img/minimal.png b/docs/img/minimal.png index e912c6c..e04d9b4 100644 Binary files a/docs/img/minimal.png and b/docs/img/minimal.png differ diff --git a/docs/img/monoblue.png b/docs/img/monoblue.png index b78c9e4..2279b2a 100644 Binary files a/docs/img/monoblue.png and b/docs/img/monoblue.png differ diff --git a/docs/img/nature.png b/docs/img/nature.png index a652355..c73ee75 100644 Binary files a/docs/img/nature.png and b/docs/img/nature.png differ diff --git a/docs/img/nord.png b/docs/img/nord.png index b654306..7a7d366 100644 Binary files a/docs/img/nord.png and b/docs/img/nord.png differ diff --git a/docs/img/retro.png b/docs/img/retro.png index 682ee06..cf52a36 100644 Binary files a/docs/img/retro.png and b/docs/img/retro.png differ diff --git a/docs/img/urban.png b/docs/img/urban.png index 1a9c321..b4ab6a4 100644 Binary files a/docs/img/urban.png and b/docs/img/urban.png differ diff --git a/docs/img/vscode-dark.png b/docs/img/vscode-dark.png index 5b3f281..efe0141 100644 Binary files a/docs/img/vscode-dark.png and b/docs/img/vscode-dark.png differ diff --git a/docs/img/wsj.png b/docs/img/wsj.png index e1d3212..f466819 100644 Binary files a/docs/img/wsj.png and b/docs/img/wsj.png differ diff --git a/docs/img/yellowish.png b/docs/img/yellowish.png index b992486..8a9565f 100644 Binary files a/docs/img/yellowish.png and b/docs/img/yellowish.png differ diff --git a/docs/index.md b/docs/index.md index 304c32a..5dfb343 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ line of code. ## Themes -**`morethemes`** offers 16 themes at the moment: +**`morethemes`** offers 16 built-in themes and a dynamic `brand` theme: === "WSJ" @@ -24,6 +24,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("wsj") ``` @@ -35,6 +36,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("urban") ``` @@ -46,6 +48,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("minimal") ``` @@ -57,6 +60,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("ft") ``` @@ -68,6 +72,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("nature") ``` @@ -79,6 +84,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("economist") ``` @@ -90,6 +96,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("greenwave") ``` @@ -101,6 +108,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("lumen") ``` @@ -112,6 +120,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("ebonis") ``` @@ -123,6 +132,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("lighter") ``` @@ -134,6 +144,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("vscode-dark") ``` @@ -145,6 +156,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("nord") ``` @@ -156,6 +168,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("retro") ``` @@ -167,6 +180,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("darker") ``` @@ -178,6 +192,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("yellowish") ``` @@ -189,6 +204,7 @@ line of code. ```python import morethemes as mt + mt.set_theme("monoblue") ``` @@ -196,6 +212,25 @@ line of code.
+### BRAND + +The `brand` theme reads a Posit +[`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and +generates Matplotlib rcParams from the brand colors and typography. +Install it with: + +``` bash +pip install "morethemes[brand]" +``` + +``` python +import morethemes as mt + +mt.set_theme("brand") +``` + +
+ ## Installation ``` bash diff --git a/docs/index.qmd b/docs/index.qmd index 225352a..96fd5d7 100644 --- a/docs/index.qmd +++ b/docs/index.qmd @@ -30,7 +30,8 @@ from: markdown-smart import morethemes as mt import matplotlib.pyplot as plt -print(f"**`morethemes`** offers {len(mt.ALL_THEMES)} themes at the moment:\n") +static_themes = [theme for theme in mt.ALL_THEMES if theme != "brand"] +print(f"**`morethemes`** offers {len(static_themes)} built-in themes and a dynamic `brand` theme:\n") def print_a_theme(theme: str): @@ -50,10 +51,25 @@ def print_a_theme(theme: str): print(theme_string) -for theme in mt.ALL_THEMES: +for theme in static_themes: print_a_theme(theme) ``` +
+ +### BRAND + +The `brand` theme reads a Posit [`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and generates Matplotlib rcParams from the brand colors and typography. Install it with: + +```bash +pip install "morethemes[brand]" +``` + +```python +import morethemes as mt +mt.set_theme("brand") +``` +
diff --git a/justfile b/justfile index c71eab8..5d80e4a 100644 --- a/justfile +++ b/justfile @@ -1,18 +1,25 @@ readme: - quarto render docs/README.qmd --output-dir .. + uv run --group quarto quarto render docs/README.qmd --output-dir .. + uv run ruff format README.md index: - quarto render docs/index.qmd + quarto render docs/index.qmd coverage: - uv run coverage run --source=morethemes -m pytest - uv run coverage report -m - uv run coverage xml - uv run genbadge coverage -i coverage.xml - rm coverage.xml + uv run coverage run --source=morethemes -m pytest + uv run coverage report -m + uv run coverage xml + uv run genbadge coverage -i coverage.xml + rm coverage.xml preview: - uv run mkdocs serve + uv run mkdocs serve test: - uv run pytest + uv run pytest + +check: + uv run ty check + uv run pyrefly check + uv run ruff check + uv run ruff format --check . diff --git a/morethemes/__init__.py b/morethemes/__init__.py index 66b2f7a..c30ece3 100644 --- a/morethemes/__init__.py +++ b/morethemes/__init__.py @@ -1,5 +1,5 @@ -from morethemes.main import set_theme, ALL_THEMES, get_rcparams +from morethemes.main import ALL_THEMES, get_rcparams, set_theme from morethemes.preview import preview_theme -__all__ = ["set_theme", "preview_theme", "ALL_THEMES", "get_rcparams"] +__all__ = ["ALL_THEMES", "get_rcparams", "preview_theme", "set_theme"] __version__ = "0.7.0" diff --git a/morethemes/brand.py b/morethemes/brand.py new file mode 100644 index 0000000..a26c510 --- /dev/null +++ b/morethemes/brand.py @@ -0,0 +1,151 @@ +import os +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from cycler import cycler + +RcParams = dict[str, Any] + + +def get_brand_rcparams() -> RcParams: + """Load the discovered brand.yml file and convert it to Matplotlib rcParams.""" + brand = _load_brand() + return _brand_to_rcparams(brand) + + +def _load_brand() -> Any: + try: + from brand_yml import Brand + except ImportError as exc: + raise ImportError( + "The 'brand' theme requires the optional 'brand_yml' dependency. " + "Install it with `pip install 'morethemes[brand]'`." + ) from exc + + path = os.environ.get("BRAND_YML_PATH", Path.cwd()) + return Brand.from_yaml(path) + + +def _brand_to_rcparams(brand: Any) -> RcParams: + rcparams: RcParams = {} + color = getattr(brand, "color", None) + typography = getattr(brand, "typography", None) + + _add_color_rcparams(rcparams, color) + _add_typography_rcparams(rcparams, typography) + + return rcparams + + +def _add_color_rcparams(rcparams: RcParams, color: Any) -> None: + if color is None: + return + + foreground = getattr(color, "foreground", None) + background = getattr(color, "background", None) + primary = getattr(color, "primary", None) + secondary = getattr(color, "secondary", None) + + if foreground is not None: + rcparams.update( + { + "text.color": foreground, + "axes.labelcolor": foreground, + "axes.edgecolor": foreground, + "xtick.color": foreground, + "ytick.color": foreground, + } + ) + if background is not None: + rcparams.update({"figure.facecolor": background, "axes.facecolor": background}) + if primary is not None: + rcparams["axes.titlecolor"] = primary + if secondary is not None: + rcparams["legend.edgecolor"] = secondary + + palette = getattr(color, "palette", None) + if isinstance(palette, Mapping) and palette: + rcparams["axes.prop_cycle"] = cycler("color", list(palette.values())) + + +def _add_typography_rcparams(rcparams: RcParams, typography: Any) -> None: + if typography is None: + return + + base = getattr(typography, "base", None) + headings = getattr(typography, "headings", None) + + if base is not None: + family = getattr(base, "family", None) + weight = getattr(base, "weight", None) + size = getattr(base, "size", None) + color = getattr(base, "color", None) + + if family is not None: + rcparams["font.family"] = family + if weight is not None: + rcparams["font.weight"] = _font_weight(weight) + if size is not None: + rcparams["font.size"] = _font_size(size) + if color is not None: + rcparams["text.color"] = color + + if headings is not None: + weight = getattr(headings, "weight", None) + size = getattr(headings, "size", None) + color = getattr(headings, "color", None) + + if weight is not None: + rcparams["axes.titleweight"] = _font_weight(weight) + if size is not None: + rcparams["axes.titlesize"] = _font_size(size) + if color is not None: + rcparams["axes.titlecolor"] = color + + +def _font_weight(weight: Any) -> Any: + if isinstance(weight, str): + weights = { + "thin": 100, + "extra-light": 200, + "extralight": 200, + "light": 300, + "normal": 400, + "regular": 400, + "medium": 500, + "semi-bold": 600, + "semibold": 600, + "bold": 700, + "extra-bold": 800, + "extrabold": 800, + "black": 900, + } + return weights.get(weight.lower(), weight) + return weight + + +def _font_size(size: Any) -> Any: + if isinstance(size, (int, float)): + return size + if not isinstance(size, str): + return size + + value = size.strip().lower() + for unit, factor in (("px", 0.75), ("pt", 1.0), ("rem", 12.0), ("em", 12.0)): + if value.endswith(unit): + return float(value[: -len(unit)]) * factor + return size + + +def get_brand_font_source(brand: Any, family: str) -> str: + """Return the declared source for a brand font family.""" + typography = getattr(brand, "typography", None) + fonts = getattr(typography, "fonts", None) if typography is not None else None + if not isinstance(fonts, Iterable): + return "system" + + for font in fonts: + if getattr(font, "family", None) == family: + return getattr(font, "source", "system") or "system" + return "system" diff --git a/morethemes/main.py b/morethemes/main.py index 359a51a..38d7d1f 100644 --- a/morethemes/main.py +++ b/morethemes/main.py @@ -3,8 +3,10 @@ import matplotlib as mpl import matplotlib.pyplot as plt +from matplotlib.typing import RcKeyType from pyfonts import load_google_font, set_default_font +from morethemes.brand import get_brand_font_source, get_brand_rcparams from morethemes.themes import ALL_THEMES RcParams = dict[str, Any] @@ -27,23 +29,48 @@ def set_theme(theme_name: str, reset_to_default: bool = True) -> None: if theme_name.lower() == "default": plt.rcParams.update(mpl.rcParamsDefault) else: - theme_dict = get_rcparams(theme_name) - try: - font_family = theme_dict["font.family"] - except KeyError: - raise KeyError( - f"Theme '{theme_name}' is missing the required 'font.family' rcParam." - ) - if not isinstance(font_family, str): - raise TypeError( - f"Theme '{theme_name}' has invalid 'font.family': " - f"expected str, got {type(font_family).__name__}." - ) - set_default_font(load_google_font(font_family)) + if theme_name.lower() == "brand": + theme_dict = get_brand_rcparams() + _set_brand_font(theme_dict) + else: + theme_dict = get_rcparams(theme_name) + _set_static_theme_font(theme_name, theme_dict) if reset_to_default: plt.rcParams.update(mpl.rcParamsDefault) - for key, value in theme_dict.items(): - plt.rcParams[key] = value + plt.rcParams.update(cast(dict[RcKeyType, Any], theme_dict)) + + +def _set_static_theme_font(theme_name: str, theme_dict: RcParams) -> None: + try: + font_family = theme_dict["font.family"] + except KeyError: + raise KeyError( + f"Theme '{theme_name}' is missing the required 'font.family' rcParam." + ) + if not isinstance(font_family, str): + raise TypeError( + f"Theme '{theme_name}' has invalid 'font.family': " + f"expected str, got {type(font_family).__name__}." + ) + set_default_font(load_google_font(font_family)) + + +def _set_brand_font(theme_dict: RcParams) -> None: + font_family = theme_dict.get("font.family") + if not isinstance(font_family, str): + return + + from morethemes.brand import _load_brand + + brand = _load_brand() + source = get_brand_font_source(brand, font_family).lower() + if source == "google": + set_default_font(load_google_font(font_family)) + elif source not in {"system", ""}: + raise ValueError( + f"The 'brand' theme does not support the '{source}' font source " + "for Matplotlib fonts. Use a system or Google font." + ) def get_rcparams(theme_name: str) -> RcParams: @@ -66,6 +93,8 @@ def get_rcparams(theme_name: str) -> RcParams: If the theme name is not found. """ theme_name = theme_name.lower() + if theme_name == "brand": + return get_brand_rcparams() try: theme_dict = ALL_THEMES[theme_name]["theme"] except KeyError: diff --git a/morethemes/preview.py b/morethemes/preview.py index 18912c2..5e5e5d4 100644 --- a/morethemes/preview.py +++ b/morethemes/preview.py @@ -1,7 +1,8 @@ import matplotlib.pyplot as plt +import numpy as np from matplotlib.figure import Figure + import morethemes as mt -import numpy as np def preview_theme(theme=None) -> Figure: diff --git a/morethemes/themes.py b/morethemes/themes.py index fcf8d6a..6ee443e 100644 --- a/morethemes/themes.py +++ b/morethemes/themes.py @@ -1,6 +1,5 @@ from cycler import cycler - darker_theme = { "name": "darker", "description": "A sleek, no-frills dark theme with high contrast and a modern feel", @@ -401,6 +400,7 @@ "description": "A sleek, dark-themed aesthetic with vibrant green accents, inspired by modern streaming interfaces. Designed for clarity and style, with bold typography and a focus on visual harmony.", "theme": { "font.family": "Plus Jakarta Sans", + "font.weight": 800, "axes.facecolor": "#000000", "figure.facecolor": "#000000", "axes.labelcolor": "#FFFFFF", @@ -598,8 +598,14 @@ }, } +brand_theme = { + "name": "brand", + "description": "A dynamic theme generated from a _brand.yml file.", + "theme": {}, +} ALL_THEMES = { + "brand": brand_theme, "wsj": wsj_theme, "urban": urban_theme, "minimal": minimal_theme, diff --git a/pyproject.toml b/pyproject.toml index d9e1710..bb9034e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "morethemes" version = "0.7.0" description = "More themes for matplotlib" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] authors = [ @@ -13,7 +13,7 @@ keywords = ["colors", "matplotlib", "style", "themes"] classifiers = [ "Framework :: Matplotlib", "Operating System :: OS Independent", - "Programming Language :: Python :: 3" + "Programming Language :: Python :: 3", ] dependencies = [ @@ -27,6 +27,9 @@ Homepage = "https://y-sunflower.github.io/morethemes/" Issues = "https://github.com/y-sunflower/morethemes/issues" Repository = "https://github.com/y-sunflower/morethemes" +[project.optional-dependencies] +brand = ["brand_yml>=0.2.0"] + [dependency-groups] dev = [ "coverage>=7.9.1", @@ -35,9 +38,11 @@ dev = [ "mkdocs-material>=9.5.49", "mkdocs-matplotlib>=0.10.1", "pre-commit>=4.0.1", + "pyrefly", "pytest>=8.3.4", + "ruff>=0.16.5", "setuptools>=75.8.0", - "ty>=0.0.32", + "ty", ] quarto = [ "jupyter>=1.1.1", diff --git a/tests/test_main.py b/tests/test_main.py index 7f7e190..eabb8dc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,9 @@ -from matplotlib.figure import Figure +import sys + import matplotlib as mpl import matplotlib.pyplot as plt import pytest +from matplotlib.figure import Figure import morethemes as mt @@ -18,17 +20,135 @@ def test_invalid_theme(): def test_valid_themes(): """Check that all valid theme names don't raise an error""" - for theme in mt.ALL_THEMES.keys(): - mt.set_theme(theme) - assert "name" in mt.ALL_THEMES[theme].keys() - assert "theme" in mt.ALL_THEMES[theme].keys() - assert "description" in mt.ALL_THEMES[theme].keys() + for theme in mt.ALL_THEMES: + assert "name" in mt.ALL_THEMES[theme] + assert "theme" in mt.ALL_THEMES[theme] + assert "description" in mt.ALL_THEMES[theme] assert len(mt.ALL_THEMES[theme]["description"]) < 200, ( f"The description of theme {theme} has too many characters. " f"It must be below 200, not {len(mt.ALL_THEMES[theme]['description'])}" ) +def test_valid_static_themes(): + """Check that all built-in themes can still be applied.""" + for theme in mt.ALL_THEMES: + if theme != "brand": + mt.set_theme(theme) + + +def test_greenwave_font_weight(): + """Keep greenwave's bold typography when applying the theme.""" + mt.set_theme("greenwave") + + assert plt.rcParams["font.weight"] == 800 + + +def test_brand_rcparams(tmp_path, monkeypatch): + """Convert a brand.yml file into Matplotlib rcParams.""" + pytest.importorskip("brand_yml") + (tmp_path / "_brand.yml").write_text( + """ +color: + palette: + blue: "#123456" + orange: "#ff6600" + foreground: blue + background: "#ffffff" + primary: orange + secondary: blue +typography: + base: + family: DejaVu Sans + weight: medium + size: 16px + headings: + family: DejaVu Sans + weight: semi-bold + color: orange +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + rcparams = mt.get_rcparams("BRAND") + + assert rcparams["font.family"] == "DejaVu Sans" + assert rcparams["font.weight"] == 500 + assert rcparams["font.size"] == 12 + assert rcparams["axes.titleweight"] == 600 + assert rcparams["axes.titlecolor"] == "#ff6600" + assert rcparams["text.color"] == "#123456" + assert rcparams["figure.facecolor"] == "#ffffff" + assert rcparams["axes.facecolor"] == "#ffffff" + assert rcparams["legend.edgecolor"] == "#123456" + assert [color["color"] for color in rcparams["axes.prop_cycle"]] == [ + "#123456", + "#ff6600", + ] + + +def test_set_brand_theme(tmp_path, monkeypatch): + """Apply a parsed brand theme to Matplotlib.""" + pytest.importorskip("brand_yml") + (tmp_path / "_brand.yml").write_text( + "color:\n foreground: '#111111'\n background: '#eeeeee'\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + mt.set_theme("brand") + + assert plt.rcParams["text.color"] == "#111111" + assert plt.rcParams["figure.facecolor"] == "#eeeeee" + + +def test_brand_missing_dependency(monkeypatch): + """Explain how to install the optional dependency.""" + monkeypatch.setitem(sys.modules, "brand_yml", None) + + with pytest.raises(ImportError, match=r"morethemes\[brand\]"): + mt.get_rcparams("brand") + + +def test_brand_missing_file(tmp_path, monkeypatch): + """Surface the parser's missing-file error.""" + pytest.importorskip("brand_yml") + monkeypatch.chdir(tmp_path) + + with pytest.raises(FileNotFoundError): + mt.get_rcparams("brand") + + +def test_brand_invalid_file(tmp_path, monkeypatch): + """Surface validation errors from brand_yml.""" + pytest.importorskip("brand_yml") + (tmp_path / "_brand.yml").write_text("color: invalid\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError): + mt.get_rcparams("brand") + + +def test_brand_unsupported_font_source(tmp_path, monkeypatch): + """Reject font sources that cannot be registered by the Matplotlib adapter.""" + pytest.importorskip("brand_yml") + (tmp_path / "_brand.yml").write_text( + """ +typography: + fonts: + - family: Brand Font + source: bunny + base: Brand Font +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="does not support"): + mt.set_theme("brand") + + def test_set_theme_default(): """Check that resetting the default style actually works""" plt.rcParams["lines.linewidth"] = 3.1415