From c5131d269a14aef99917ef280395d07410a64c85 Mon Sep 17 00:00:00 2001 From: everbarry Date: Thu, 18 Dec 2025 00:48:17 +0100 Subject: [PATCH] add backend --- .gitignore | 10 ++ README.org | 60 +++++++ main.py | 106 +++++++++++++ pyproject.toml | 9 ++ test.py | 413 +++++++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 57 +++++++ 6 files changed, 655 insertions(+) create mode 100644 .gitignore create mode 100644 README.org create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 test.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/README.org b/README.org new file mode 100644 index 0000000..b1e152a --- /dev/null +++ b/README.org @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..07af50c --- /dev/null +++ b/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e36c7cf --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/test.py b/test.py new file mode 100644 index 0000000..a220a41 --- /dev/null +++ b/test.py @@ -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) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7d54d9f --- /dev/null +++ b/uv.lock @@ -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" }, +]