Source code for exlab_wizard.sync.bandwidth

"""Bandwidth schedule evaluator. Backend Spec §7.1.7.

The §7.1.7 bandwidth limiter is per-equipment and per-window: an operator
declares an ``upload_mbps`` cap and an optional list of ``schedule``
windows. The cap applies inside any active window; outside the windows
the transport runs unthrottled.

This module is the pure evaluator: given a :class:`BandwidthConfig` and a
local-time ``datetime``, return the effective bandwidth limit in KiB/s
(``--bwlimit`` units) or ``None`` for unlimited. The conversion is
``upload_mbps * 1024 / 8`` per §7.1.7; the spec uses **megabits**, rclone
expects **kibibytes/s**, and the helper rounds to the nearest integer.
"""

from __future__ import annotations

from datetime import datetime, time

from exlab_wizard.config.models import BandwidthConfig, BandwidthWindow

__all__ = [
    "DAY_NAME_TO_INDEX",
    "effective_bandwidth_limit_kibps",
    "is_window_active",
    "mbps_to_kibps",
]


# Map a §9 short day name to ``datetime.weekday()`` (0 = Monday).
DAY_NAME_TO_INDEX: dict[str, int] = {
    "mon": 0,
    "tue": 1,
    "wed": 2,
    "thu": 3,
    "fri": 4,
    "sat": 5,
    "sun": 6,
}


[docs] def mbps_to_kibps(upload_mbps: float) -> int: """Convert ``upload_mbps`` (megabits/s) into ``--bwlimit`` KiB/s. Per §7.1.7: ``K = upload_mbps * 1024 / 8``. Rounded to the nearest integer. Returns 1 KiB/s as the floor when the input is positive but rounds to zero so a configured limit always throttles something. """ if upload_mbps <= 0: return 0 raw = upload_mbps * 1024.0 / 8.0 rounded = round(raw) return max(1, int(rounded))
def _parse_hhmm(value: str) -> time: """Parse an ``HH:MM`` string. Pydantic validated it on input; :func:`is_window_active` revalidates so the helper is usable on non-Pydantic inputs (e.g. unit tests with hand-built dicts). """ return time.fromisoformat(value)
[docs] def is_window_active(window: BandwidthWindow, now_local: datetime) -> bool: """Return True iff ``now_local`` is inside ``window``. The window is defined by ``days`` (a list of three-letter day names) and ``from``/``to`` HH:MM strings (interpreted as workstation-local time). The window is half-open: ``from <= t < to``. Cross-midnight windows are not supported by §9 (validated at config load). """ weekday_index = now_local.weekday() weekday_name = next( (name for name, idx in DAY_NAME_TO_INDEX.items() if idx == weekday_index), None, ) if weekday_name is None or weekday_name not in window.days: return False from_t = _parse_hhmm(window.from_) to_t = _parse_hhmm(window.to) cur_t = now_local.time().replace(microsecond=0) return from_t <= cur_t < to_t
[docs] def effective_bandwidth_limit_kibps( cfg: BandwidthConfig, *, now_local: datetime, ) -> int | None: """Return the effective ``--bwlimit`` in KiB/s for ``now_local``. Decision tree per §7.1.7: 1. If ``cfg.upload_mbps`` is ``None`` -> unlimited (``None``). 2. Else if ``cfg.schedule`` is empty -> the cap applies always. 3. Else if ``now_local`` falls inside any schedule window -> the cap applies for this transfer. 4. Else -> unlimited (``None``). """ if cfg.upload_mbps is None: return None if not cfg.schedule: return mbps_to_kibps(cfg.upload_mbps) for window in cfg.schedule: if is_window_active(window, now_local): return mbps_to_kibps(cfg.upload_mbps) return None