backpack-backbone 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backbone/__init__.py +30 -0
- backbone/app.py +26 -0
- backbone/datetime_parse.py +224 -0
- backbone/deps.py +98 -0
- backbone/files.py +61 -0
- backbone/keyboard.py +148 -0
- backbone/keys.py +271 -0
- backbone/log.py +61 -0
- backbone/nav.py +17 -0
- backbone/notify.py +36 -0
- backbone/numbering.py +182 -0
- backbone/output.py +216 -0
- backbone/procs.py +60 -0
- backbone/prompt/__init__.py +20 -0
- backbone/prompt/audio.py +496 -0
- backbone/prompt/chrome.py +238 -0
- backbone/prompt/core.py +1562 -0
- backbone/prompt/dates.py +555 -0
- backbone/prompt/keymap.py +143 -0
- backbone/prompt/list_edit.py +974 -0
- backbone/prompt/lists.py +1280 -0
- backbone/prompt/text.py +356 -0
- backbone/prompt/timezone.py +1543 -0
- backbone/prompt/values.py +581 -0
- backbone/terminal_input.py +60 -0
- backbone/timefmt.py +34 -0
- backbone/ui.py +1044 -0
- backpack_backbone-0.2.0.dist-info/METADATA +177 -0
- backpack_backbone-0.2.0.dist-info/RECORD +32 -0
- backpack_backbone-0.2.0.dist-info/WHEEL +5 -0
- backpack_backbone-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backbone-0.2.0.dist-info/top_level.txt +1 -0
backbone/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""backbone - shared terminal UI: colours, meters, prompt widgets, live views."""
|
|
2
|
+
from .ui import (
|
|
3
|
+
Colors,
|
|
4
|
+
MARGIN_H,
|
|
5
|
+
MARGIN_V,
|
|
6
|
+
SPIN,
|
|
7
|
+
PARTS,
|
|
8
|
+
SPARK,
|
|
9
|
+
spinner,
|
|
10
|
+
content_width,
|
|
11
|
+
rule,
|
|
12
|
+
bar,
|
|
13
|
+
header_box,
|
|
14
|
+
wrap_margins,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Colors",
|
|
19
|
+
"MARGIN_H",
|
|
20
|
+
"MARGIN_V",
|
|
21
|
+
"SPIN",
|
|
22
|
+
"PARTS",
|
|
23
|
+
"SPARK",
|
|
24
|
+
"spinner",
|
|
25
|
+
"content_width",
|
|
26
|
+
"rule",
|
|
27
|
+
"bar",
|
|
28
|
+
"header_box",
|
|
29
|
+
"wrap_margins",
|
|
30
|
+
]
|
backbone/app.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Which program is running, so shared code can find its files: a name and a
|
|
2
|
+
config folder, set once at startup by the host program (its config module)."""
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _default_dir(name: str) -> Path:
|
|
8
|
+
"""The platform's usual config folder for `name`."""
|
|
9
|
+
if os.name == "nt":
|
|
10
|
+
base = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
11
|
+
return Path(base) / name.capitalize()
|
|
12
|
+
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
13
|
+
return (Path(xdg) if xdg else Path.home() / ".config") / name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
name = "backbone"
|
|
17
|
+
config_dir = _default_dir(name)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure(app_name: str, app_config_dir=None) -> None:
|
|
21
|
+
"""Name the running program and its config folder (the platform default for
|
|
22
|
+
that name when not given). The diagnostics log and the saved hints switch
|
|
23
|
+
live there."""
|
|
24
|
+
global name, config_dir
|
|
25
|
+
name = app_name
|
|
26
|
+
config_dir = Path(app_config_dir) if app_config_dir else _default_dir(app_name)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""One parser for every hand-typed date: the calendar, the date/time editor,
|
|
2
|
+
the schedule table, filename values.
|
|
3
|
+
|
|
4
|
+
Everything goes through :func:`parse_datetime`. It accepts what a person
|
|
5
|
+
plausibly types, keeps whatever precision was given, and says *why* when it can't
|
|
6
|
+
read something so the caller can show that rather than a bare failure.
|
|
7
|
+
|
|
8
|
+
Accepted, all with ``-``, ``/`` or ``.`` between the parts and zero-padding
|
|
9
|
+
optional::
|
|
10
|
+
|
|
11
|
+
2008 2008-07 2008-07-02 2008-7-2
|
|
12
|
+
2008/07/02 2008.7.2 20080702
|
|
13
|
+
2008-07-02 18:30 2008-07-02T18:30 2008-07-02 18:30:45
|
|
14
|
+
|
|
15
|
+
A time may follow the date after a ``T`` (either case) or a space, as ``HH:MM``
|
|
16
|
+
or ``HH:MM:SS``. A trailing timezone (``Z`` or ``±HH:MM``) is stripped: the
|
|
17
|
+
tags Backtrack writes are local wall-clock timestamps.
|
|
18
|
+
|
|
19
|
+
Day-first vs month-first (``02/07/2008``) is ambiguous and is resolved
|
|
20
|
+
only when the caller says how, via ``dayfirst``. Left unset, an ambiguous date
|
|
21
|
+
is refused rather than guessed, because guessing wrong writes a plausible-looking
|
|
22
|
+
wrong date that nobody notices.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import datetime
|
|
27
|
+
import re
|
|
28
|
+
from typing import NamedTuple, Optional
|
|
29
|
+
|
|
30
|
+
__all__ = ['ParsedDateTime', 'parse_datetime', 'parse_date', 'parse_time',
|
|
31
|
+
'format_datetime', 'PRECISIONS']
|
|
32
|
+
|
|
33
|
+
# Coarse → fine. A caller can compare precisions with `PRECISIONS.index(...)`
|
|
34
|
+
# to demand at least a given granularity.
|
|
35
|
+
PRECISIONS = ('year', 'month', 'day', 'minute', 'second')
|
|
36
|
+
|
|
37
|
+
# Year-first, any of - / . between parts, zero-padding optional.
|
|
38
|
+
_YEAR_FIRST_RE = re.compile(r'^(\d{4})(?:[-/.\s](\d{1,2})(?:[-/.\s](\d{1,2}))?)?$')
|
|
39
|
+
# ISO basic form, 20080702.
|
|
40
|
+
_COMPACT_RE = re.compile(r'^(\d{4})(\d{2})(\d{2})$')
|
|
41
|
+
# Day- or month-first, e.g. 02/07/2008, order decided by `dayfirst`.
|
|
42
|
+
_YEAR_LAST_RE = re.compile(r'^(\d{1,2})[-/.\s](\d{1,2})[-/.\s](\d{4})$')
|
|
43
|
+
# A trailing timezone we drop rather than try to honour.
|
|
44
|
+
_TZ_RE = re.compile(r'(Z|[+-]\d{2}:?\d{2})$', re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParsedDateTime(NamedTuple):
|
|
48
|
+
"""The result of reading a date/time a user typed.
|
|
49
|
+
|
|
50
|
+
``date`` is always a real ``datetime.date`` on success (a year- or
|
|
51
|
+
month-only input is completed to the 1st so callers that just need *a* date
|
|
52
|
+
have one), and ``precision`` records how much was actually given, so a caller
|
|
53
|
+
that needs a real day (a schedule counting in days, say) can insist on it
|
|
54
|
+
instead of silently scheduling from an invented 1 January.
|
|
55
|
+
|
|
56
|
+
``time`` is ``'HH:MM:SS'`` or None. ``error`` is '' on success and otherwise
|
|
57
|
+
a short phrase naming what was wrong, fit to show the user directly.
|
|
58
|
+
"""
|
|
59
|
+
date: Optional[datetime.date]
|
|
60
|
+
time: Optional[str]
|
|
61
|
+
precision: str
|
|
62
|
+
error: str
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ok(self) -> bool:
|
|
66
|
+
"""True if the input parsed."""
|
|
67
|
+
return not self.error
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_time(raw) -> Optional[str]:
|
|
71
|
+
"""Normalise ``HH``/``HH:MM``/``HH:MM:SS`` to ``'HH:MM:SS'``; None if unreadable.
|
|
72
|
+
|
|
73
|
+
Rejects out-of-range parts (``25:00``, ``18:75``) rather than rolling them
|
|
74
|
+
over, so a typo surfaces instead of quietly becoming a different time.
|
|
75
|
+
"""
|
|
76
|
+
if raw is None:
|
|
77
|
+
return None
|
|
78
|
+
s = str(raw).strip()
|
|
79
|
+
if not s:
|
|
80
|
+
return None
|
|
81
|
+
parts = s.split(':')
|
|
82
|
+
if len(parts) > 3 or not all(p.strip().isdigit() for p in parts if p.strip() != ''):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
h = int(parts[0])
|
|
86
|
+
m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
|
|
87
|
+
sec = int(parts[2]) if len(parts) > 2 and parts[2].strip() else 0
|
|
88
|
+
except (ValueError, IndexError):
|
|
89
|
+
return None
|
|
90
|
+
if 0 <= h < 24 and 0 <= m < 60 and 0 <= sec < 60:
|
|
91
|
+
return f"{h:02d}:{m:02d}:{sec:02d}"
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _split_date_time(s: str) -> tuple:
|
|
96
|
+
"""Split a stamp into its date and time halves.
|
|
97
|
+
|
|
98
|
+
A ``T`` always separates them. A space only does when what follows it looks
|
|
99
|
+
like a clock time (it carries a ``:``) because a space is *also* a legal
|
|
100
|
+
separator inside the date itself: ``2008 07 02`` is a date, while
|
|
101
|
+
``2008-07-02 18:30`` is a date and a time.
|
|
102
|
+
"""
|
|
103
|
+
upper = s.upper()
|
|
104
|
+
if 'T' in upper:
|
|
105
|
+
cut = upper.index('T')
|
|
106
|
+
return s[:cut].strip(), s[cut + 1:].strip()
|
|
107
|
+
if ' ' in s:
|
|
108
|
+
head, _, tail = s.rpartition(' ')
|
|
109
|
+
if ':' in tail:
|
|
110
|
+
return head.strip(), tail.strip()
|
|
111
|
+
return s.strip(), ''
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def split_stamp(raw) -> tuple:
|
|
115
|
+
"""The date and time halves of a typed stamp, any trailing timezone dropped."""
|
|
116
|
+
return _split_date_time(_TZ_RE.sub('', str(raw or '').strip()).strip())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_date(part: str, dayfirst: Optional[bool]) -> tuple:
|
|
120
|
+
"""Parse the date half → ``(date, precision, error)``."""
|
|
121
|
+
m = _COMPACT_RE.match(part)
|
|
122
|
+
if m:
|
|
123
|
+
y, mo, d = (int(g) for g in m.groups())
|
|
124
|
+
return _build(y, mo, d, 'day')
|
|
125
|
+
|
|
126
|
+
m = _YEAR_FIRST_RE.match(part)
|
|
127
|
+
if m:
|
|
128
|
+
year, month, day = m.groups()
|
|
129
|
+
if month is None:
|
|
130
|
+
return _build(int(year), 1, 1, 'year')
|
|
131
|
+
if day is None:
|
|
132
|
+
return _build(int(year), int(month), 1, 'month')
|
|
133
|
+
return _build(int(year), int(month), int(day), 'day')
|
|
134
|
+
|
|
135
|
+
m = _YEAR_LAST_RE.match(part)
|
|
136
|
+
if m:
|
|
137
|
+
a, b, year = (int(g) for g in m.groups())
|
|
138
|
+
# Only one ordering can be right when a part exceeds 12 (13/07/2008 has
|
|
139
|
+
# to be day-first), so try both and see how many survive.
|
|
140
|
+
day_first_ok = 1 <= b <= 12
|
|
141
|
+
month_first_ok = 1 <= a <= 12
|
|
142
|
+
if day_first_ok and month_first_ok:
|
|
143
|
+
# Genuinely ambiguous: 02/07/2008 is 2 July or 2 February depending
|
|
144
|
+
# on where you live. Honour an explicit choice, else refuse rather
|
|
145
|
+
# than pick one and be silently wrong.
|
|
146
|
+
if dayfirst is None:
|
|
147
|
+
return None, '', (f"{part!r} could be day-first or month-first: "
|
|
148
|
+
"write it year-first (2008-07-02)")
|
|
149
|
+
day, month = (a, b) if dayfirst else (b, a)
|
|
150
|
+
elif day_first_ok:
|
|
151
|
+
day, month = a, b
|
|
152
|
+
elif month_first_ok:
|
|
153
|
+
month, day = a, b
|
|
154
|
+
else:
|
|
155
|
+
return None, '', f"{part!r} has no valid month"
|
|
156
|
+
return _build(year, month, day, 'day')
|
|
157
|
+
|
|
158
|
+
return None, '', f"{part!r} is not a date"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build(year: int, month: int, day: int, precision: str) -> tuple:
|
|
162
|
+
"""Validate y/m/d into a real date, or report why it isn't one."""
|
|
163
|
+
if not 1 <= month <= 12:
|
|
164
|
+
return None, '', f"there is no month {month}"
|
|
165
|
+
try:
|
|
166
|
+
return datetime.date(year, month, day), precision, ''
|
|
167
|
+
except ValueError:
|
|
168
|
+
return None, '', (f"{year:04d}-{month:02d}-{day:02d} is not a real date")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_datetime(raw, *, dayfirst: Optional[bool] = None) -> ParsedDateTime:
|
|
172
|
+
"""Read a date, optionally with a time, from something a user typed.
|
|
173
|
+
|
|
174
|
+
``dayfirst`` resolves ``02/07/2008``: True reads it day-first, False
|
|
175
|
+
month-first, and the default (None) refuses it and says to write the date
|
|
176
|
+
year-first. Year-first input is never ambiguous and never consults it.
|
|
177
|
+
"""
|
|
178
|
+
if raw is None:
|
|
179
|
+
return ParsedDateTime(None, None, '', 'no date given')
|
|
180
|
+
date_part, time_part = split_stamp(raw)
|
|
181
|
+
if not date_part:
|
|
182
|
+
return ParsedDateTime(None, None, '', 'no date given')
|
|
183
|
+
|
|
184
|
+
date, precision, err = _read_date(date_part, dayfirst)
|
|
185
|
+
if err:
|
|
186
|
+
return ParsedDateTime(None, None, '', err)
|
|
187
|
+
|
|
188
|
+
if not time_part:
|
|
189
|
+
return ParsedDateTime(date, None, precision, '')
|
|
190
|
+
|
|
191
|
+
tod = parse_time(time_part)
|
|
192
|
+
if tod is None:
|
|
193
|
+
return ParsedDateTime(None, None, '', f"{time_part!r} is not a valid 24-hour time")
|
|
194
|
+
# A time implies a full date; without one we would be timing an invented day.
|
|
195
|
+
if precision != 'day':
|
|
196
|
+
return ParsedDateTime(None, None, '', f"{date_part!r} needs a full year-month-day "
|
|
197
|
+
"to carry a time")
|
|
198
|
+
return ParsedDateTime(date, tod, 'second' if tod[-2:] != '00' else 'minute', '')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_date(raw, *, dayfirst: Optional[bool] = None) -> Optional[datetime.date]:
|
|
202
|
+
"""Just the date, or None if it won't parse. Any time given is ignored."""
|
|
203
|
+
return parse_datetime(raw, dayfirst=dayfirst).date
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_datetime(parsed: ParsedDateTime) -> str:
|
|
207
|
+
"""Render a parse back out at the precision it was given."""
|
|
208
|
+
if parsed.date is None:
|
|
209
|
+
return ''
|
|
210
|
+
if parsed.precision == 'year':
|
|
211
|
+
return f"{parsed.date.year:04d}"
|
|
212
|
+
if parsed.precision == 'month':
|
|
213
|
+
return f"{parsed.date.year:04d}-{parsed.date.month:02d}"
|
|
214
|
+
if parsed.time:
|
|
215
|
+
return f"{parsed.date.isoformat()} {parsed.time}"
|
|
216
|
+
return parsed.date.isoformat()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_date_parts(raw, *, dayfirst: Optional[bool] = None) -> Optional[tuple]:
|
|
220
|
+
"""``(year, month, day)`` for a typed date, or None: the shape the calendar
|
|
221
|
+
and date/time widgets work in. A year- or month-only input completes to the
|
|
222
|
+
1st, as those widgets have always done."""
|
|
223
|
+
d = parse_datetime(raw, dayfirst=dayfirst).date
|
|
224
|
+
return (d.year, d.month, d.day) if d else None
|
backbone/deps.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What a tool needs besides Python, checked in one place: at startup for what
|
|
2
|
+
it can't run without (`require`), and as a report for everything (each tool's
|
|
3
|
+
`doctor` command), each with how to install it on the platform it's on.
|
|
4
|
+
|
|
5
|
+
VLC = Dep("VLC", probe=..., needed_for="playback", required=True,
|
|
6
|
+
hints={"macos": "brew install vlc", "debian": "sudo apt install vlc"})
|
|
7
|
+
deps.require([VLC]) # prints the hint and exits when VLC is missing
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from backbone.log import log
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Dep:
|
|
21
|
+
name: str
|
|
22
|
+
probe: Callable[[], str | None] # what was found (a path, a version), or None
|
|
23
|
+
needed_for: str
|
|
24
|
+
required: bool = False
|
|
25
|
+
# Install hint per platform (see platform_key); "other" when none fits.
|
|
26
|
+
hints: dict = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def which(*commands: str) -> Callable[[], str | None]:
|
|
30
|
+
"""A probe for the first of `commands` on the PATH."""
|
|
31
|
+
return lambda: next((p for c in commands if (p := shutil.which(c))), None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def platform_key() -> str:
|
|
35
|
+
"""macos, windows, debian (Ubuntu too), fedora, arch, or linux."""
|
|
36
|
+
if sys.platform == "darwin":
|
|
37
|
+
return "macos"
|
|
38
|
+
if os.name == "nt":
|
|
39
|
+
return "windows"
|
|
40
|
+
ids = ""
|
|
41
|
+
try:
|
|
42
|
+
with open("/etc/os-release", encoding="utf-8") as f:
|
|
43
|
+
for line in f:
|
|
44
|
+
if line.startswith(("ID=", "ID_LIKE=")):
|
|
45
|
+
ids += " " + line.split("=", 1)[1].strip().strip('"').lower()
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
for key, names in (("debian", ("debian", "ubuntu")), ("fedora", ("fedora", "rhel")),
|
|
49
|
+
("arch", ("arch",))):
|
|
50
|
+
if any(n in ids.split() for n in names):
|
|
51
|
+
return key
|
|
52
|
+
return "linux"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hint(dep: Dep) -> str:
|
|
56
|
+
"""How to install `dep` here."""
|
|
57
|
+
key = platform_key()
|
|
58
|
+
return (dep.hints.get(key) or (dep.hints.get("linux") if key in ("debian", "fedora", "arch") else None)
|
|
59
|
+
or dep.hints.get("other", ""))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check(deps: list[Dep]) -> list[tuple[Dep, str | None]]:
|
|
63
|
+
"""Each dependency with what was found, or None when it's missing. A probe
|
|
64
|
+
that fails counts as missing (and is logged)."""
|
|
65
|
+
out = []
|
|
66
|
+
for dep in deps:
|
|
67
|
+
try:
|
|
68
|
+
found = dep.probe() or None
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
log.info("dependency %s: probe failed: %s", dep.name, exc)
|
|
71
|
+
found = None
|
|
72
|
+
out.append((dep, found))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def report_lines(deps: list[Dep]) -> list[str]:
|
|
77
|
+
"""One line per dependency, for a plain-text doctor."""
|
|
78
|
+
lines = []
|
|
79
|
+
for dep, found in check(deps):
|
|
80
|
+
if found:
|
|
81
|
+
lines.append(f" ok {dep.name}: {found}")
|
|
82
|
+
else:
|
|
83
|
+
need = "MISSING " if dep.required else "missing "
|
|
84
|
+
lines.append(f" {need} {dep.name} (for {dep.needed_for}): {hint(dep)}")
|
|
85
|
+
return lines
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def require(deps: list[Dep], tool: str) -> None:
|
|
89
|
+
"""Exit with what to install when a required dependency is missing, before
|
|
90
|
+
the tool starts rather than as a traceback once it's running."""
|
|
91
|
+
missing = [d for d, found in check(deps) if d.required and not found]
|
|
92
|
+
if not missing:
|
|
93
|
+
return
|
|
94
|
+
print(f"{tool} can't start: something it needs isn't installed.", file=sys.stderr)
|
|
95
|
+
for d in missing:
|
|
96
|
+
print(f" {d.name} (for {d.needed_for}): {hint(d)}", file=sys.stderr)
|
|
97
|
+
print(f"`{tool} doctor` lists everything it uses.", file=sys.stderr)
|
|
98
|
+
raise SystemExit(1)
|
backbone/files.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Files: atomic writes and backups, a daemon's timestamped log, free space and folder counts."""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_text_atomic(path, text: str) -> None:
|
|
8
|
+
"""Write `text` to `path` via a temporary file in the same directory that
|
|
9
|
+
then replaces it: the file is either the old version or the new one, never
|
|
10
|
+
half of the new one."""
|
|
11
|
+
path = os.fspath(path)
|
|
12
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", prefix=".tmp_",
|
|
13
|
+
suffix=os.path.splitext(path)[1])
|
|
14
|
+
try:
|
|
15
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
16
|
+
f.write(text)
|
|
17
|
+
f.flush()
|
|
18
|
+
os.fsync(f.fileno())
|
|
19
|
+
os.replace(tmp, path)
|
|
20
|
+
except BaseException:
|
|
21
|
+
try:
|
|
22
|
+
os.unlink(tmp)
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
|
25
|
+
raise
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def backup_copy(path) -> str | None:
|
|
29
|
+
"""Copy `path` to `path.bak` (replacing an older one) before it's
|
|
30
|
+
overwritten; returns the backup's path, or None if there was nothing to copy."""
|
|
31
|
+
path = os.fspath(path)
|
|
32
|
+
if not os.path.isfile(path):
|
|
33
|
+
return None
|
|
34
|
+
dest = path + ".bak"
|
|
35
|
+
shutil.copy2(path, dest)
|
|
36
|
+
return dest
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def log_line(path, message: str) -> None:
|
|
40
|
+
"""Print `message` with a timestamp and append the same line to the log at
|
|
41
|
+
`path`: a daemon's running record, readable on screen and afterwards."""
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
line = f"{datetime.now():%Y-%m-%d %H:%M:%S} {message}"
|
|
44
|
+
print(line)
|
|
45
|
+
with open(path, "a") as f:
|
|
46
|
+
f.write(line + "\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def disk_free(path) -> str:
|
|
50
|
+
"""Free space on `path`'s volume as `df -h` shows it, or "?"."""
|
|
51
|
+
import subprocess
|
|
52
|
+
try:
|
|
53
|
+
lines = subprocess.run(["df", "-h", os.fspath(path)], capture_output=True, text=True, timeout=10).stdout.splitlines()
|
|
54
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
55
|
+
return "?"
|
|
56
|
+
return lines[-1].split()[3] if len(lines) > 1 else "?"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def count_entries(d) -> int:
|
|
60
|
+
"""How many entries the folder `d` holds, 0 if it doesn't exist."""
|
|
61
|
+
return len(os.listdir(d)) if os.path.isdir(d) else 0
|
backbone/keyboard.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Which keyboard the typist is using, only as far as search needs it.
|
|
2
|
+
|
|
3
|
+
`search._lev` scores a spelling mistake by whether the wrong letter sits next to
|
|
4
|
+
the right one, so the only thing that matters here is where the *letters* are.
|
|
5
|
+
That collapses the world's keyboards into a handful of families: British, US,
|
|
6
|
+
Canadian, Irish, Australian and ABC differ solely in punctuation and dead keys,
|
|
7
|
+
and are all one QWERTY as far as a typo is concerned.
|
|
8
|
+
|
|
9
|
+
Detection is best-effort by design. Every branch is wrapped, and anything
|
|
10
|
+
unreadable, unrecognised or unknown falls back to QWERTY. `BACKTRACK_KEYBOARD` overrides it
|
|
11
|
+
outright, which is the only thing that can be right over SSH: the layout lives
|
|
12
|
+
on the machine in front of the typist, not the one running the process.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
# Row-by-row key positions. Punctuation is kept in place even though nothing
|
|
20
|
+
# looks it up: it holds the columns open, so dropping the leading "'," from
|
|
21
|
+
# Dvorak's top row would slide every letter on it one key to the left.
|
|
22
|
+
LAYOUTS: dict[str, tuple[str, ...]] = {
|
|
23
|
+
'qwerty': ("1234567890-=", "qwertyuiop[]", "asdfghjkl;'", "zxcvbnm,./"),
|
|
24
|
+
'qwertz': ("1234567890ß'", "qwertzuiopü+", "asdfghjklöä", "yxcvbnm,.-"),
|
|
25
|
+
'azerty': ("1234567890°+", "azertyuiop^$", "qsdfghjklmù", "wxcvbn,;:!"),
|
|
26
|
+
'dvorak': ("1234567890[]", "',.pyfgcrl/=", "aoeuidhtns-", ";qjkxbmwvz"),
|
|
27
|
+
'colemak': ("1234567890-=", "qwfpgjluy;[]", "arstdhneio'", "zxcvbkm,./"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DEFAULT = 'qwerty'
|
|
31
|
+
|
|
32
|
+
# Layout names (macOS), XKB codes (Linux) and language ids (Windows) that move
|
|
33
|
+
# letters around. Matched as substrings against a lowercased name, so "Swiss
|
|
34
|
+
# German" and "German - Standard" both land on QWERTZ.
|
|
35
|
+
_NAME_FAMILIES: tuple[tuple[str, str], ...] = (
|
|
36
|
+
('dvorak', 'dvorak'), ('colemak', 'colemak'),
|
|
37
|
+
('azerty', 'azerty'), ('french', 'azerty'), ('belgian', 'azerty'),
|
|
38
|
+
('qwertz', 'qwertz'), ('german', 'qwertz'), ('swiss', 'qwertz'),
|
|
39
|
+
('austrian', 'qwertz'), ('czech', 'qwertz'), ('slovak', 'qwertz'),
|
|
40
|
+
('hungarian', 'qwertz'), ('croatian', 'qwertz'), ('serbian', 'qwertz'),
|
|
41
|
+
('slovenian', 'qwertz'), ('bosnian', 'qwertz'),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_XKB_FAMILIES: dict[str, str] = {
|
|
45
|
+
'fr': 'azerty', 'be': 'azerty',
|
|
46
|
+
'de': 'qwertz', 'ch': 'qwertz', 'at': 'qwertz', 'cz': 'qwertz',
|
|
47
|
+
'sk': 'qwertz', 'hu': 'qwertz', 'hr': 'qwertz', 'rs': 'qwertz',
|
|
48
|
+
'si': 'qwertz', 'ba': 'qwertz',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Windows primary language ids (the low byte of the low word of the HKL).
|
|
52
|
+
_WIN_FAMILIES: dict[int, str] = {
|
|
53
|
+
0x0c: 'azerty', # French (incl. Belgian)
|
|
54
|
+
0x13: 'azerty', # Dutch; Belgian is AZERTY
|
|
55
|
+
0x07: 'qwertz', 0x05: 'qwertz', 0x0e: 'qwertz', # German, Czech, Hungarian
|
|
56
|
+
0x1b: 'qwertz', 0x24: 'qwertz', 0x1a: 'qwertz', # Slovak, Slovenian, Croatian
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _family_from_name(name: str) -> str | None:
|
|
61
|
+
"""Map a human layout name ("ABC - AZERTY", "Swiss German") to a family."""
|
|
62
|
+
low = name.lower()
|
|
63
|
+
for needle, family in _NAME_FAMILIES:
|
|
64
|
+
if needle in low:
|
|
65
|
+
return family
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _detect_macos() -> str | None:
|
|
70
|
+
"""The selected input source, straight out of the HIToolbox preferences.
|
|
71
|
+
|
|
72
|
+
Read with plistlib rather than shelling out to `defaults`: no subprocess on
|
|
73
|
+
startup, and no dependency on pyobjc for the Carbon TIS API.
|
|
74
|
+
"""
|
|
75
|
+
import plistlib
|
|
76
|
+
path = os.path.expanduser("~/Library/Preferences/com.apple.HIToolbox.plist")
|
|
77
|
+
with open(path, 'rb') as fh:
|
|
78
|
+
prefs = plistlib.load(fh)
|
|
79
|
+
for source in prefs.get('AppleSelectedInputSources', []):
|
|
80
|
+
if source.get('InputSourceKind') != 'Keyboard Layout':
|
|
81
|
+
continue
|
|
82
|
+
# A selected source can be one with no letter arrangement of its own:
|
|
83
|
+
# "Unicode Hex Input" is US letters with hex entry bolted on. Unnamed
|
|
84
|
+
# families fall through to QWERTY rather than guessing from whatever
|
|
85
|
+
# else the user happens to have enabled.
|
|
86
|
+
return _family_from_name(str(source.get('KeyboardLayout Name', '')))
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _detect_linux() -> str | None:
|
|
91
|
+
"""XKB layout: the X server first, then systemd, then the Debian default."""
|
|
92
|
+
import subprocess
|
|
93
|
+
|
|
94
|
+
for cmd, key in ((['setxkbmap', '-query'], 'layout:'),
|
|
95
|
+
(['localectl', 'status'], 'x11 layout:')):
|
|
96
|
+
try:
|
|
97
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
|
|
98
|
+
except (OSError, subprocess.SubprocessError):
|
|
99
|
+
continue
|
|
100
|
+
for line in out.splitlines():
|
|
101
|
+
low = line.strip().lower()
|
|
102
|
+
if low.startswith(key):
|
|
103
|
+
# "layout: gb,fr": the first is the active one.
|
|
104
|
+
code = low[len(key):].strip().split(',')[0].strip()
|
|
105
|
+
if code:
|
|
106
|
+
return _XKB_FAMILIES.get(code, DEFAULT)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with open('/etc/default/keyboard') as fh:
|
|
110
|
+
for line in fh:
|
|
111
|
+
if line.startswith('XKBLAYOUT='):
|
|
112
|
+
code = line.split('=', 1)[1].strip().strip('"\'').split(',')[0]
|
|
113
|
+
if code:
|
|
114
|
+
return _XKB_FAMILIES.get(code, DEFAULT)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _detect_windows() -> str | None:
|
|
121
|
+
"""Active keyboard layout of the foreground thread, via user32.
|
|
122
|
+
|
|
123
|
+
The HKL's low word is a language id, which is as far as this gets: it names
|
|
124
|
+
French but cannot tell US Dvorak from US QWERTY (that lives in the registry,
|
|
125
|
+
keyed by device). Dvorak and Colemak users on Windows want the env var.
|
|
126
|
+
"""
|
|
127
|
+
import ctypes
|
|
128
|
+
hkl = ctypes.windll.user32.GetKeyboardLayout(0) # type: ignore[attr-defined]
|
|
129
|
+
return _WIN_FAMILIES.get(hkl & 0xff, DEFAULT)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def detect() -> str:
|
|
133
|
+
"""Name the keyboard family in front of the user. Never raises."""
|
|
134
|
+
override = (os.environ.get('BACKTRACK_KEYBOARD') or '').strip().lower()
|
|
135
|
+
if override in LAYOUTS:
|
|
136
|
+
return override
|
|
137
|
+
|
|
138
|
+
detector = ({'darwin': _detect_macos, 'win32': _detect_windows}
|
|
139
|
+
.get(sys.platform, _detect_linux))
|
|
140
|
+
try:
|
|
141
|
+
return detector() or DEFAULT
|
|
142
|
+
except Exception:
|
|
143
|
+
return DEFAULT
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def rows(name: str | None = None) -> tuple[str, ...]:
|
|
147
|
+
"""Key rows for a layout family, or for the detected one."""
|
|
148
|
+
return LAYOUTS.get(name or detect(), LAYOUTS[DEFAULT])
|