From f7d946b4a5fdb3dac6c23ebbb839a944232b1ba0 Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Mon, 19 Oct 2026 16:29:04 +0200 Subject: [PATCH] Publish a first version of battery_guard on PyPI --- .gitignore | 7 +++ LICENSE | 21 +++++++++ README.md | 47 ++++++++++++++++++++ battery_guard.py | 110 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 24 +++++++++++ uv.lock | 42 ++++++++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 battery_guard.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..351d836 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.venv/ +dist/ +build/ +*.egg-info/ +.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8ed744b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bart Broere + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5cff25 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# battery-guard +*A Python library to pause work until the battery is charged enough to continue* + +Designed for power-hungry laptops connected to slow chargers. + +Use it as a decorator on your methods. In the example below, each call to `process` first checks the battery. +If it is below 25%, execution is paused until it is back at or above that same 25%. + +```python +from battery_guard import battery_guard + +@battery_guard(minimum=25) +def process(item): + ... # a power hungry task probably + +for item in items: + process(item) +``` + +The battery is only checked when the decorated function is called, a call that is already running is never paused. +It therefore works best on a function that is called repeatedly, like the body of a loop, rather than on one long-running function. + +To pause when the battery dips below 20%, and only continue once it has charged back up to 30%, pass `resume` as well: + +```python +@battery_guard(minimum=20, resume=30) +``` + +When there is no function to decorate, for example in a notebook cell, block directly: + +```python +from battery_guard import wait_for_battery + +wait_for_battery(minimum=25) +``` + +## Logging + +Messages are emitted through the standard `logging` module, under the `battery_guard` logger. +Warnings (such as failing to read the battery status) are shown by default. +To also see when execution pauses and resumes, enable `INFO` level, or `DEBUG` to see every battery check: + +```python +import logging + +logging.basicConfig(level=logging.INFO) +``` diff --git a/battery_guard.py b/battery_guard.py new file mode 100644 index 0000000..babd3cb --- /dev/null +++ b/battery_guard.py @@ -0,0 +1,110 @@ +import functools +import logging +import time + +try: + import psutil +except ImportError: + psutil = None + +logger = logging.getLogger(__name__) + + +def _check_arguments(minimum, resume, wait_interval): + if resume is None: + resume = minimum + if not 0 <= minimum <= 100: + raise ValueError(f"minimum ({minimum}) must be between 0 and 100") + if not minimum <= resume <= 100: + raise ValueError(f"resume ({resume}) must be between minimum ({minimum}) and 100") + if wait_interval <= 0: + raise ValueError(f"wait_interval ({wait_interval}) must be positive") + return resume + + +def wait_for_battery(minimum=20, resume=None, wait_interval=5): + """ + Return right away if the battery is at least `minimum` percent. + + If it is below `minimum`, block until it has charged back up to `resume` + percent (defaults to `minimum`). While blocking, it checks the battery every + `wait_interval` seconds (5 by default). + + This is useful where there is no function to decorate, for example in a notebook cell. + See `battery_guard` for the decorator version and advice on choosing `minimum`. + """ + resume = _check_arguments(minimum, resume, wait_interval) + if psutil is None: + logger.warning("Missing psutil library, not sleeping because battery percentage is unknown") + return + waited = False + while True: + try: + battery = psutil.sensors_battery() + if battery is None: + logger.debug("psutil.sensors_battery() reports no battery, resuming") + return + threshold = resume if waited else minimum + if battery.percent >= threshold: # noqa, None comparison can't be reached because of the return above + if waited: + logger.info("Battery at %s%%, resuming", battery.percent) + return + if not waited: + logger.info("Battery at %s%%, waiting until it reaches %s%%", + battery.percent, resume) + waited = True + logger.debug("Battery at %s%%, still waiting", battery.percent) + time.sleep(wait_interval) + except Exception: # noqa: unsupported platform / sensor read failure / who knows -> log it! + logger.warning("Could not read battery status, not sleeping", exc_info=True) + return + + +def battery_guard(func=None, *, minimum=20, resume=None, wait_interval=5): + """ + Call the function right away if the battery is at least `minimum` percent. + + If it is below `minimum`, block until it has charged back up to `resume` + percent (defaults to `minimum`), then call the function. + By default it will check every 5 seconds (`wait_interval`) before it continues. + + The battery is only checked when the decorated function is called, a call that + is already running is never paused. It therefore works best on a function that + is called repeatedly, like the body of a loop that processes one item at a time, + rather than on one long-running function. To block without decorating anything, + use `wait_for_battery`. + + Note that battery charging slows down when it's near a full charge. + It also may produce more heat and consume more power. + Therefore it's probably better to not set minimum too high. + The recommended range for minimum is between 20 and 80 percent. + + There are valid reasons to set minimum higher though, you simply might want + to be able to take it with you and expect it to be charged as much as possible. + Or maybe you want a heavy background task to pause once you leave the office, + and the battery dipping below 95% is the indication that you have left for the train. + + Setting it to 100% might also give unexpected results, because some battery management + systems will not start charging until the reported percentage drops below 95% for example. + The percentage psutil.sensors_battery() reports also isn't necessarily the same as the + percentage displayed in your system tray. + + This method does not consider whether a charger is plugged in. That is on purpose. + The reason it exists is to deal with chargers that are too slow; a situation + where battery percentage decreases despite having a charger plugged in. + + Usable as: + @battery_guard + @battery_guard(minimum=20, resume=25, wait_interval=30) + battery_guard(some_func, minimum=50)(arg1, arg2) + """ + resume = _check_arguments(minimum, resume, wait_interval) + + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + wait_for_battery(minimum=minimum, resume=resume, wait_interval=wait_interval) + return f(*args, **kwargs) + return wrapper + + return decorator if func is None else decorator(func) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b474989 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "battery-guard" +version = "2026.10.19" +description = "Pause work until the battery is charged enough to continue" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Bart Broere", email = "mail@bartbroere.eu" }] +requires-python = ">=3.11" +dependencies = ["psutil"] +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["battery_guard.py"] + +[tool.hatch.build.targets.sdist] +only-include = ["battery_guard.py", "README.md", "LICENSE"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..35e3735 --- /dev/null +++ b/uv.lock @@ -0,0 +1,42 @@ +version = 1 +revision = 5 +requires-python = ">=3.10" + +[[package]] +name = "battery-guard" +version = "2026.10.19" +source = { editable = "." } +dependencies = [ + { name = "psutil" }, +] + +[package.metadata] +requires-dist = [{ name = "psutil" }] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +]