add backend

This commit is contained in:
everbarry 2025-12-18 00:48:17 +01:00
commit c5131d269a
6 changed files with 655 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

60
README.org Normal file
View File

@ -0,0 +1,60 @@
* PiFanControlTUI
Welcome, this project aim to provide a simple minimal intuitive TUI to set your RaspberryPi 5 fan curves.
** Install This
#TODO
** Workings
This project works by setting the following dtparams in =/boot/firmware/config.txt=. Settings require reboot to work.
#+begin_src txt
[all]
dtparam=fan_temp0=55000,fan_temp0_hyst=2000,fan_temp0_speed=90
dtparam=fan_temp1=65000,fan_temp1_hyst=3000,fan_temp1_speed=160
dtparam=fan_temp2=70000,fan_temp2_hyst=4000,fan_temp2_speed=200
dtparam=fan_temp3=72000,fan_temp3_hyst=5000,fan_temp3_speed=255
#+end_src
1. *fan_tempN*: temperature fan activates at in millicelsius
2. *fan_tempN_hyst*: fan turns off (or to lower setting) when temperature is =fan_tempN - fan_tempN_hyst=. Also in millicelsius
3. *fan_tempN_speed*: fan speed in range [0, 255] where 255 is 100% fan speed
The RaspberryPi 5 firmware supports up to 4 different [[https://github.com/raspberrypi/firmware/blob/4aa2a51fecb99ce16fe591391f6779174b9b5bea/boot/overlays/README#L271][thresholds]].
The following are the default values used by the firmware:
#+begin_src txt
fan_temp0 Temperature threshold (in millicelcius) for
1st cooling level (default 50000).
fan_temp0_hyst Temperature hysteresis (in millicelcius) for
1st cooling level (default 5000).
fan_temp0_speed Fan PWM setting for 1st cooling level (0-255,
default 75).
fan_temp1 Temperature threshold (in millicelcius) for
2nd cooling level (default 60000).
fan_temp1_hyst Temperature hysteresis (in millicelcius) for
2nd cooling level (default 5000).
fan_temp1_speed Fan PWM setting for 2nd cooling level (0-255,
default 125).
fan_temp2 Temperature threshold (in millicelcius) for
3rd cooling level (default 67500).
fan_temp2_hyst Temperature hysteresis (in millicelcius) for
3rd cooling level (default 5000).
fan_temp2_speed Fan PWM setting for 3rd cooling level (0-255,
default 175).
fan_temp3 Temperature threshold (in millicelcius) for
4th cooling level (default 75000).
fan_temp3_hyst Temperature hysteresis (in millicelcius) for
4th cooling level (default 5000).
fan_temp3_speed Fan PWM setting for 4th cooling level (0-255,
default 250).
#+end_src

106
main.py Normal file
View File

@ -0,0 +1,106 @@
#!/usr/bin/python3
import difflib
import tempfile
import shutil
from tui import TUI
class DtParam:
__slots__ = ('temp', 'hyst', 'speed', 'N')
def __init__(self, temp : int, hyst : int, speed : int, n: int):
self.temp = temp # Temperature in MilliCelsius
self.hyst = hyst # Temperature Hysteresis in MilliCelsius
self.speed = speed # Fan Speed in 0-255
self.N = n # Fan Threshold number
self._assert_valid()
def _assert_valid(self):
assert self.temp // 1000 > 0 and self.temp // 1000 < 100, f"Temperature threshold not in sensible range (0, 100) C, found {self.temp}"
assert self.hyst // 1000 > 0 and self.hyst // 1000 < 100, f"Temperature hysteresis threshold not in sensible range (0, 100) C, found {self.temp}"
assert self.speed >= 0 and self.speed <= 255, f"Fan speed not in value range (0, 255), found {self.speed}"
assert self.N >= 0 and self.N <= 3, f"Threshold number not in supported set (0, 1, 2, 3), found {self.N}"
def __str__(self):
return f"dtparam=fan_temp{self.N}={self.temp},fan_temp{self.N}_hyst={self.hyst},fan_temp{self.N}_speed={self.speed}"
class FanCurve:
def __init__(self):
self.config_path = "/tmp/boot/firmware/config.txt"
self.settings = self._get_current_settings()
self._assert_valid_cfg()
def update(self, settings: list[DtParam]):
self.settings = settings
self.settings.sort(key=lambda s: s.temp)
for i, setting in enumerate(self.settings):
setting.N = i
self._assert_valid_cfg()
self._write_cfg()
def _write_cfg(self):
with open(self.config_path, "r") as f:
lines = f.readlines()
old_cfg = [line for line in lines if not (line.startswith("dtparam=fan_temp") or line.startswith("# Auto-Generated Fan Curve"))]
insert_at = None
for i, line in enumerate(old_cfg):
if line.strip() == "[all]":
insert_at = i
new_lines = ["# Auto-Generated Fan Curve parameters by PiFanUI\n"]
new_lines.extend([str(setting)+"\n" for setting in self.settings])
new_cfg = old_cfg[:insert_at + 1] + new_lines + old_cfg[insert_at + 1:]
if new_cfg == lines:
print("No new changes")
return
with tempfile.NamedTemporaryFile("w", dir="/tmp", delete=False) as new_conf:
new_conf.writelines(new_cfg)
diff = difflib.unified_diff(lines, new_cfg, fromfile=self.config_path, tofile=new_conf.name)
print(f"Updated values:")
for line in diff:
print(line)
shutil.move(new_conf.name, self.config_path)
def _assert_valid_cfg(self):
assert len(self.settings) <= 4 and len(self.settings) >= 1, f"Only up to 4 thresholds supported, found {len(self.settings)}"
assert sorted(self.settings, key=lambda s:s.temp) == sorted(self.settings, key=lambda s:s.N), f"Thresholds are not ordered correctly:\n{[str(s) for s in self.settings]}"
def _get_default_settings(self) -> list[DtParam]:
settings = []
settings.append(DtParam(temp=55000, hyst=2000, speed=90, n=0))
settings.append(DtParam(temp=65000, hyst=3000, speed=160, n=1))
settings.append(DtParam(temp=70000, hyst=4000, speed=200, n=2))
settings.append(DtParam(temp=72000, hyst=5000, speed=255, n=3))
return settings
def _get_current_settings(self) -> list[DtParam]:
settings = []
with open(self.config_path, "r") as f:
lines = f.readlines()
config_lines = [line.replace(" ", "").lower().split("#")[0][8:] for line in lines if line.startswith("dtparam=fan_temp")]
config_lines = sorted([item[8:].replace("_", "=") for string in config_lines for item in string.split(',')])
cfg = [item[2:].rstrip('\n') for item in config_lines]
# TODO if not specified fill in with defaults
assert len(cfg) % 3 == 0, "Partially Specified Fan Behaviour found, make sure to specify _temp, _hyst & _speed"
for i in range(0, len(config_lines), 3):
settings.append(DtParam(temp=int(cfg[i]), hyst=int(cfg[i+1][5:]), speed=int(cfg[i+2][6:]), n=i//3))
return settings
def main():
sc = FanCurve()
tui = TUI(sc)
print("Bye from pifantui!")
if __name__ == "__main__":
main()

9
pyproject.toml Normal file
View File

@ -0,0 +1,9 @@
[project]
name = "pifantui"
version = "0.1.0"
description = "Minimal Pi5 Fan Control UI"
readme = "README.org"
requires-python = ">=3.14"
dependencies = [
"rich>=14.2.0",
]

413
test.py Normal file
View File

@ -0,0 +1,413 @@
import io
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from main import DtParam, FanCurve
def _write_config(path: str, content: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
class TestDtParamAsserts(unittest.TestCase):
def test_temp_must_be_in_sensible_range(self) -> None:
with self.assertRaises(AssertionError):
DtParam(temp=0, hyst=2000, speed=90, n=0)
with self.assertRaises(AssertionError):
DtParam(temp=100_000, hyst=2000, speed=90, n=0)
def test_hyst_must_be_in_sensible_range(self) -> None:
with self.assertRaises(AssertionError):
DtParam(temp=55_000, hyst=0, speed=90, n=0)
with self.assertRaises(AssertionError):
DtParam(temp=55_000, hyst=100_000, speed=90, n=0)
def test_speed_must_be_in_range(self) -> None:
with self.assertRaises(AssertionError):
DtParam(temp=55_000, hyst=2000, speed=-1, n=0)
with self.assertRaises(AssertionError):
DtParam(temp=55_000, hyst=2000, speed=256, n=0)
def test_threshold_index_must_be_supported(self) -> None:
with self.assertRaises(AssertionError):
DtParam(temp=55_000, hyst=2000, speed=90, n=-1)
with self.assertRaises(AssertionError):
DtParam(temp=55_000, hyst=2000, speed=90, n=4)
class TestFanCurveAsserts(unittest.TestCase):
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.fc = FanCurve.__new__(FanCurve)
self.fc.config_path = os.path.join(self.tmpdir.name, "config.txt")
_write_config(
self.fc.config_path,
"\n".join(
[
"# test config",
"[all]",
"dtoverlay=dwc2,dr_mode=host",
"",
]
)
+ "\n",
)
def tearDown(self) -> None:
self.tmpdir.cleanup()
def test_assert_valid_cfg_allows_1_to_4_thresholds(self) -> None:
self.fc.settings = [DtParam(temp=55_000, hyst=2000, speed=90, n=0)]
self.fc._assert_valid_cfg()
self.fc.settings = [
DtParam(temp=55_000, hyst=2000, speed=90, n=0),
DtParam(temp=65_000, hyst=3000, speed=160, n=1),
DtParam(temp=70_000, hyst=4000, speed=200, n=2),
DtParam(temp=72_000, hyst=5000, speed=255, n=3),
]
self.fc._assert_valid_cfg()
def test_assert_valid_cfg_rejects_0_or_more_than_4_thresholds(self) -> None:
self.fc.settings = []
with self.assertRaises(AssertionError):
self.fc._assert_valid_cfg()
self.fc.settings = [
DtParam(temp=51_000, hyst=2000, speed=1, n=0),
DtParam(temp=52_000, hyst=2000, speed=2, n=1),
DtParam(temp=53_000, hyst=2000, speed=3, n=2),
DtParam(temp=54_000, hyst=2000, speed=4, n=3),
DtParam(temp=55_000, hyst=2000, speed=5, n=0),
]
with self.assertRaises(AssertionError):
self.fc._assert_valid_cfg()
def test_assert_valid_cfg_requires_temp_order_matches_threshold_number_order(self) -> None:
a = DtParam(temp=55_000, hyst=2000, speed=90, n=3)
b = DtParam(temp=65_000, hyst=3000, speed=160, n=2)
c = DtParam(temp=70_000, hyst=4000, speed=200, n=1)
d = DtParam(temp=72_000, hyst=5000, speed=255, n=0)
self.fc.settings = [a, b, c, d]
with self.assertRaises(AssertionError):
self.fc._assert_valid_cfg()
def test_assert_valid_cfg_fails_when_temps_not_increasing_by_threshold_number(self) -> None:
a = DtParam(temp=65_000, hyst=2000, speed=90, n=0)
b = DtParam(temp=55_000, hyst=3000, speed=160, n=1)
c = DtParam(temp=70_000, hyst=4000, speed=200, n=2)
d = DtParam(temp=72_000, hyst=5000, speed=255, n=3)
self.fc.settings = [a, b, c, d]
with self.assertRaises(AssertionError):
self.fc._assert_valid_cfg()
class TestFanCurveWriteBehavior(unittest.TestCase):
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.fc = FanCurve.__new__(FanCurve)
self.fc.config_path = os.path.join(self.tmpdir.name, "config.txt")
_write_config(
self.fc.config_path,
"\n".join(
[
"# test config",
"[all]",
"dtoverlay=dwc2,dr_mode=host",
"",
]
)
+ "\n",
)
def tearDown(self) -> None:
self.tmpdir.cleanup()
def test_update_writes_fan_curve_and_is_quiet_when_unchanged(self) -> None:
# First run should write and typically print a diff.
buf1 = io.StringIO()
with redirect_stdout(buf1):
self.fc.update(self.fc._get_default_settings())
with open(self.fc.config_path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("# Auto-Generated Fan Curve parameters by PiFanUI\n", content)
self.assertIn("dtparam=fan_temp0=", content)
self.assertIn("dtparam=fan_temp3=", content)
# Second run should be a no-op and print nothing.
buf2 = io.StringIO()
with redirect_stdout(buf2):
self.fc.update(self.fc._get_default_settings())
# A no-op run must not print a diff.
self.assertNotIn("Updated values:", buf2.getvalue())
def test_update_rewrites_when_settings_change(self) -> None:
# Write defaults.
with redirect_stdout(io.StringIO()):
self.fc.update(self.fc._get_default_settings())
with open(self.fc.config_path, "r", encoding="utf-8") as f:
before = f.read()
# Change one setting (speed) and update again.
changed = self.fc._get_default_settings()
changed[0] = DtParam(temp=55_000, hyst=2000, speed=91, n=0)
buf = io.StringIO()
with redirect_stdout(buf):
self.fc.update(changed)
with open(self.fc.config_path, "r", encoding="utf-8") as f:
after = f.read()
self.assertNotEqual(before, after)
self.assertIn("Updated values:", buf.getvalue())
self.assertIn("fan_temp0_speed=91", after)
def test_update_renumbers_thresholds_by_temperature(self) -> None:
# Provide thresholds out of temp order and with incorrect N values.
s0 = DtParam(temp=72_000, hyst=5000, speed=255, n=2)
s1 = DtParam(temp=55_000, hyst=2000, speed=90, n=3)
s2 = DtParam(temp=70_000, hyst=4000, speed=200, n=0)
s3 = DtParam(temp=65_000, hyst=3000, speed=160, n=1)
settings = [s0, s1, s2, s3]
with redirect_stdout(io.StringIO()):
self.fc.update(settings)
with open(self.fc.config_path, "r", encoding="utf-8") as f:
dtparam_lines = [line.rstrip("\n") for line in f if line.startswith("dtparam=fan_temp")]
# Extract (N, temp) from "dtparam=fan_tempN=temp,..."
pairs: list[tuple[int, int]] = []
for line in dtparam_lines:
# Note: line contains two '=' characters: "dtparam=fan_tempN=temp,..."
tail = line[len("dtparam=fan_temp") :] # "N=temp,..."
n_str, rest = tail.split("=", 1) # "N", "temp,..."
n = int(n_str)
temp = int(rest.split(",", 1)[0])
pairs.append((n, temp))
self.assertEqual(sorted(n for n, _ in pairs), [0, 1, 2, 3])
# N order must correspond to increasing temps.
temps_by_n = [temp for _, temp in sorted(pairs, key=lambda p: p[0])]
self.assertEqual(temps_by_n, sorted(temps_by_n))
# Also assert the passed-in objects were renumbered in-place by update().
self.assertEqual(sorted(s.N for s in settings), [0, 1, 2, 3])
self.assertEqual([s.N for s in sorted(settings, key=lambda s: s.temp)], [0, 1, 2, 3])
def test_written_dtparam_lines_match_dtparam_str(self) -> None:
settings = self.fc._get_default_settings()
# Pre-seed the config with stale/duplicate fan_temp lines to ensure update deletes them.
_write_config(
self.fc.config_path,
"\n".join(
[
"# test config",
"[all]",
"# Auto-Generated Fan Curve parameters by PiFanUI",
"dtparam=fan_temp0=1,fan_temp0_hyst=1,fan_temp0_speed=1",
"dtparam=fan_temp1=2,fan_temp1_hyst=2,fan_temp1_speed=2",
"dtparam=fan_temp2=3,fan_temp2_hyst=3,fan_temp2_speed=3",
"dtparam=fan_temp3=4,fan_temp3_hyst=4,fan_temp3_speed=4",
# duplicates that should be removed
"dtparam=fan_temp0=999,fan_temp0_hyst=999,fan_temp0_speed=999",
"dtparam=fan_temp3=999,fan_temp3_hyst=999,fan_temp3_speed=999",
"",
]
)
+ "\n",
)
with redirect_stdout(io.StringIO()):
self.fc.update(settings)
with open(self.fc.config_path, "r", encoding="utf-8") as f:
all_lines = list(f)
# Ensure deletion worked: the only remaining fan_temp dtparam lines are the 4 expected ones.
self.assertEqual(sum(1 for line in all_lines if "dtparam=fan_temp" in line), 4)
written_lines = [line.rstrip("\n") for line in all_lines if line.startswith("dtparam=fan_temp")]
expected_lines = [str(s) for s in settings]
self.assertEqual(written_lines, expected_lines)
def test_update_preserves_unrelated_config_lines(self) -> None:
original = "\n".join(
[
"# test config",
"dtparam=audio=on",
"",
"[pi5]",
"dtoverlay=foo",
"",
"[all]",
"dtoverlay=dwc2,dr_mode=host",
"dtparam=i2c_arm=on",
"",
"[extra]",
"somekey=somevalue",
"",
]
) + "\n"
_write_config(self.fc.config_path, original)
with redirect_stdout(io.StringIO()):
self.fc.update(self.fc._get_default_settings())
with open(self.fc.config_path, "r", encoding="utf-8") as f:
after_lines = f.readlines()
# Strip out the auto-generated fan curve block; remaining content should match exactly.
after_stripped = [
line
for line in after_lines
if not (line.startswith("dtparam=fan_temp") or line.startswith("# Auto-Generated Fan Curve"))
]
self.assertEqual(after_stripped, original.splitlines(keepends=True))
class TestFanCurveGetCurrentSettings(unittest.TestCase):
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.fc = FanCurve.__new__(FanCurve)
self.fc.config_path = os.path.join(self.tmpdir.name, "config.txt")
def tearDown(self) -> None:
self.tmpdir.cleanup()
def _read_settings_as_tuples(self) -> list[tuple[int, int, int, int]]:
settings = self.fc._get_current_settings()
return [(s.N, s.temp, s.hyst, s.speed) for s in settings]
def test_get_current_settings_parses_separate_lines_per_param(self) -> None:
_write_config(
self.fc.config_path,
"\n".join(
[
"[all]",
"dtparam=fan_temp0=65000",
"dtparam=fan_temp0_hyst=5000",
"dtparam=fan_temp0_speed=75",
"",
"dtparam=fan_temp1=70000",
"dtparam=fan_temp1_hyst=5000",
"dtparam=fan_temp1_speed=128",
"",
"dtparam=fan_temp2=75000",
"dtparam=fan_temp2_hyst=5000",
"dtparam=fan_temp2_speed=192",
"",
]
)
+ "\n",
)
self.assertEqual(
self._read_settings_as_tuples(),
[
(0, 65000, 5000, 75),
(1, 70000, 5000, 128),
(2, 75000, 5000, 192),
],
)
def test_get_current_settings_parses_comma_separated_dtparam_lines(self) -> None:
_write_config(
self.fc.config_path,
"\n".join(
[
"[all]",
"dtparam=fan_temp0=54000,fan_temp0_hyst=2000,fan_temp0_speed=90",
"dtparam=fan_temp1=65000,fan_temp1_hyst=3000,fan_temp1_speed=160",
"dtparam=fan_temp2=70000,fan_temp2_hyst=4000,fan_temp2_speed=200",
"dtparam=fan_temp3=72000,fan_temp3_hyst=5000,fan_temp3_speed=255",
"",
]
)
+ "\n",
)
self.assertEqual(
self._read_settings_as_tuples(),
[
(0, 54000, 2000, 90),
(1, 65000, 3000, 160),
(2, 70000, 4000, 200),
(3, 72000, 5000, 255),
],
)
def test_get_current_settings_parses_three_thresholds_set(self) -> None:
_write_config(
self.fc.config_path,
"\n".join(
[
"[all]",
"dtparam=fan_temp0=54000,fan_temp0_hyst=2000,fan_temp0_speed=90",
"dtparam=fan_temp1=65000,fan_temp1_hyst=3000,fan_temp1_speed=160",
"dtparam=fan_temp2=70000,fan_temp2_hyst=4000,fan_temp2_speed=200",
"",
]
)
+ "\n",
)
self.assertEqual(
self._read_settings_as_tuples(),
[
(0, 54000, 2000, 90),
(1, 65000, 3000, 160),
(2, 70000, 4000, 200),
],
)
def test_get_current_settings_rejects_partially_specified_threshold(self) -> None:
# fan_temp0 is missing hyst and speed -> must fail rather than silently mis-parsing.
_write_config(
self.fc.config_path,
"\n".join(
[
"[all]",
"dtparam=fan_temp0=65000",
"",
]
)
+ "\n",
)
with self.assertRaises(AssertionError):
self.fc._get_current_settings()
def test_get_current_settings_parses_mixed_styles(self) -> None:
_write_config(
self.fc.config_path,
"\n".join(
[
"[all]",
# fan_temp0: all in one line
"dtparam=fan_temp0=54000,fan_temp0_hyst=2000,fan_temp0_speed=90",
# fan_temp1: split across 3 lines
"dtparam=fan_temp1=65000",
"dtparam=fan_temp1_hyst=3000",
"dtparam=fan_temp1_speed=160",
# fan_temp2: split (temp+speed) then hyst
"dtparam=fan_temp2=70000,fan_temp2_speed=200",
"dtparam=fan_temp2_hyst=4000",
# fan_temp3: all in one line
"dtparam=fan_temp3=72000,fan_temp3_hyst=5000,fan_temp3_speed=255",
"",
]
)
+ "\n",
)
self.assertEqual(
self._read_settings_as_tuples(),
[
(0, 54000, 2000, 90),
(1, 65000, 3000, 160),
(2, 70000, 4000, 200),
(3, 72000, 5000, 255),
],
)
if __name__ == "__main__":
unittest.main(verbosity=2)

57
uv.lock generated Normal file
View File

@ -0,0 +1,57 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "pifantui"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "rich" },
]
[package.metadata]
requires-dist = [{ name = "rich", specifier = ">=14.2.0" }]
[[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 = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]