pypomodo 0.3.1__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.
- pypomo/__init__.py +4 -0
- pypomo/__main__.py +3 -0
- pypomo/cli.py +193 -0
- pypomo/config.py +87 -0
- pypomo/durations.py +41 -0
- pypomo/events.py +69 -0
- pypomo/hooks.py +91 -0
- pypomo/keys.py +127 -0
- pypomo/output.py +55 -0
- pypomo/pomodoro.py +71 -0
- pypomo/pretty.py +107 -0
- pypomo/signals.py +29 -0
- pypomo/timer.py +147 -0
- pypomodo-0.3.1.dist-info/METADATA +377 -0
- pypomodo-0.3.1.dist-info/RECORD +18 -0
- pypomodo-0.3.1.dist-info/WHEEL +4 -0
- pypomodo-0.3.1.dist-info/entry_points.txt +3 -0
- pypomodo-0.3.1.dist-info/licenses/LICENSE +21 -0
pypomo/__init__.py
ADDED
pypomo/__main__.py
ADDED
pypomo/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""argparse entry point."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
import textwrap
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import NAME_RE, Config, default_config_path, load_config
|
|
11
|
+
from .durations import DURATION_RE, parse_duration
|
|
12
|
+
from .events import Events, JsonLines, Phase
|
|
13
|
+
from .hooks import HookRunner
|
|
14
|
+
from .keys import keyboard
|
|
15
|
+
from .output import flush, silence_broken_pipe, write
|
|
16
|
+
from .pomodoro import Pomodoro, run_pomodoro
|
|
17
|
+
from .signals import Terminated, cancellation_handlers
|
|
18
|
+
from .timer import format_duration, run_timer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ArgumentParser(argparse.ArgumentParser):
|
|
22
|
+
def _print_message(self, message, file=None):
|
|
23
|
+
if file is sys.stdout:
|
|
24
|
+
if message:
|
|
25
|
+
write(message, end='')
|
|
26
|
+
else:
|
|
27
|
+
super()._print_message(message, file)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DURATIONS_HELP = 'Durations combine d, h, m, and s in that order: 25m, 1h30m, 1h 30m, or 2d.'
|
|
31
|
+
DISTRIBUTION = 'pypomodo' # The PyPI name; the command and package are pypomo.
|
|
32
|
+
KEYS_HELP = ('While a timer runs in a terminal, press p or Space to pause and resume, '
|
|
33
|
+
'or q to cancel.')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def end_time(seconds: int, now: datetime | None = None) -> str:
|
|
37
|
+
"""Local clock time `seconds` from now, to the nearest minute; dated when not today."""
|
|
38
|
+
now = now or datetime.now()
|
|
39
|
+
end = now + timedelta(seconds=seconds)
|
|
40
|
+
end = (end + timedelta(seconds=30)).replace(second=0, microsecond=0)
|
|
41
|
+
return end.strftime('%H:%M' if end.date() == now.date() else '%Y-%m-%d %H:%M')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
try:
|
|
46
|
+
with cancellation_handlers():
|
|
47
|
+
try:
|
|
48
|
+
return _main(argv)
|
|
49
|
+
finally:
|
|
50
|
+
# Keep buffered output failures inside the broken-pipe boundary.
|
|
51
|
+
flush()
|
|
52
|
+
except BrokenPipeError:
|
|
53
|
+
silence_broken_pipe()
|
|
54
|
+
return 141
|
|
55
|
+
except Terminated as exc:
|
|
56
|
+
return 128 + exc.signum
|
|
57
|
+
except KeyboardInterrupt:
|
|
58
|
+
return 130
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _main(argv: list[str] | None = None) -> int:
|
|
62
|
+
parser = build_parser()
|
|
63
|
+
args = parse_args(parser, argv)
|
|
64
|
+
try:
|
|
65
|
+
config = load_config(args.config or default_config_path(), required=args.config is not None)
|
|
66
|
+
if args.list:
|
|
67
|
+
list_timers(config)
|
|
68
|
+
return 0
|
|
69
|
+
target = resolve(args.timer, config)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
parser.error(str(exc))
|
|
72
|
+
if isinstance(target, Pomodoro) and args.auto_start is not None:
|
|
73
|
+
target = replace(target, auto_start=args.auto_start)
|
|
74
|
+
if args.preview:
|
|
75
|
+
preview(args.timer, target)
|
|
76
|
+
return 0
|
|
77
|
+
pretty = replace(config.pretty, enabled=args.pretty) if args.pretty is not None else config.pretty
|
|
78
|
+
hooks = hook_runner(args, config)
|
|
79
|
+
jsonl = args.format == 'jsonl'
|
|
80
|
+
events = Events([listener for listener in (hooks, JsonLines(args.timer) if jsonl else None) if listener])
|
|
81
|
+
events = events or None
|
|
82
|
+
with keyboard() as keys:
|
|
83
|
+
if isinstance(target, Pomodoro):
|
|
84
|
+
run_pomodoro(target, args.timer, live=args.live, pretty=pretty, silent=args.silent, keys=keys,
|
|
85
|
+
events=events, display=not jsonl)
|
|
86
|
+
else:
|
|
87
|
+
on_event = events.handler(Phase(args.timer, 'timer', target)) if events else None
|
|
88
|
+
run_timer(target, args.timer, live=args.live, pretty=pretty, silent=args.silent, keys=keys,
|
|
89
|
+
on_event=on_event, display=not jsonl)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_parser() -> ArgumentParser:
|
|
94
|
+
parser = ArgumentParser(
|
|
95
|
+
description='Start a duration or named timer (e.g. 15m, 1h30m, pomo).',
|
|
96
|
+
epilog='\n\n'.join(textwrap.fill(text, 79) for text in (DURATIONS_HELP, KEYS_HELP)),
|
|
97
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument('timer', nargs='?', help='duration, timer name, pomo, or NAME.pomo')
|
|
100
|
+
parser.add_argument('--config', type=Path, help='TOML or JSON config path (default: XDG config directory or ~/.config/pypomo)')
|
|
101
|
+
parser.add_argument('--list', action='store_true', help='list available timers and Pomodoro routines')
|
|
102
|
+
parser.add_argument('--live', action='store_true', help='update one line even when output is piped')
|
|
103
|
+
parser.add_argument('--silent', action='store_true', help='suppress the completion bell')
|
|
104
|
+
parser.add_argument('--preview', action='store_true', help='show phases and total duration without starting a timer')
|
|
105
|
+
parser.add_argument('--pretty', action=argparse.BooleanOptionalAction, default=None,
|
|
106
|
+
help='enable styled output (or --no-pretty to disable it)')
|
|
107
|
+
parser.add_argument('--hook', action='append', default=[], metavar='CMD',
|
|
108
|
+
help='run CMD through the shell on every timer event (repeatable)')
|
|
109
|
+
parser.add_argument('--no-hooks', action='store_true', help='ignore hooks from the config file')
|
|
110
|
+
parser.add_argument('--format', choices=('text', 'jsonl'), default='text',
|
|
111
|
+
help='text: the countdown display (default); jsonl: one JSON object per '
|
|
112
|
+
'event on stdout, with no display')
|
|
113
|
+
parser.add_argument('--auto-start', action=argparse.BooleanOptionalAction, default=None,
|
|
114
|
+
help='start each Pomodoro phase automatically (or --no-auto-start '
|
|
115
|
+
'to wait for Enter between phases)')
|
|
116
|
+
try:
|
|
117
|
+
package_version = version(DISTRIBUTION)
|
|
118
|
+
except PackageNotFoundError:
|
|
119
|
+
package_version = 'unknown (source checkout)'
|
|
120
|
+
parser.add_argument('--version', action='version', version=f'pypomo {package_version}')
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_args(parser: ArgumentParser, argv: list[str] | None) -> argparse.Namespace:
|
|
125
|
+
"""Parse and cross-check arguments, joining an unquoted spaced duration such as 1h 30m."""
|
|
126
|
+
args, extras = parser.parse_known_args(argv)
|
|
127
|
+
if extras:
|
|
128
|
+
if any(value.startswith('-') and value[1:2].isdigit() for value in extras):
|
|
129
|
+
parser.error('Duration must be greater than zero.')
|
|
130
|
+
if not (args.timer and DURATION_RE.fullmatch(args.timer)
|
|
131
|
+
and all(DURATION_RE.fullmatch(value) for value in extras)):
|
|
132
|
+
parser.error(f"unrecognized arguments: {' '.join(extras)}")
|
|
133
|
+
args.timer = ' '.join([args.timer, *extras])
|
|
134
|
+
if args.timer is None and not args.list:
|
|
135
|
+
parser.error('provide a duration or timer name, or use --list')
|
|
136
|
+
if args.timer is not None and args.list:
|
|
137
|
+
parser.error('use --list without a timer')
|
|
138
|
+
if args.preview and args.list:
|
|
139
|
+
parser.error('use --preview with a timer or routine, not --list')
|
|
140
|
+
if args.format == 'jsonl':
|
|
141
|
+
for flag, used in (('--list', args.list), ('--preview', args.preview), ('--live', args.live)):
|
|
142
|
+
if used:
|
|
143
|
+
parser.error(f'--format jsonl is for running timers; it cannot be used with {flag}')
|
|
144
|
+
return args
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve(name: str, config: Config) -> Pomodoro | int:
|
|
148
|
+
"""Return the routine or the number of seconds that `name` refers to."""
|
|
149
|
+
if name == 'pomo':
|
|
150
|
+
return config.pomo
|
|
151
|
+
if name.endswith('.pomo'):
|
|
152
|
+
routine = config.pomodoros.get(name[:-5])
|
|
153
|
+
if routine is None:
|
|
154
|
+
raise ValueError(f'Unknown Pomodoro routine {name!r}. Use --list to see available routines.')
|
|
155
|
+
return routine
|
|
156
|
+
if name in config.timers:
|
|
157
|
+
return config.timers[name]
|
|
158
|
+
if NAME_RE.fullmatch(name):
|
|
159
|
+
raise ValueError(f'Unknown timer {name!r}. Use --list to see available timers.')
|
|
160
|
+
return parse_duration(name, config.default_units)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_timers(config: Config) -> None:
|
|
164
|
+
for name, seconds in sorted(config.timers.items()):
|
|
165
|
+
write(f'{name}: {format_duration(seconds)}')
|
|
166
|
+
routines = {'pomo': config.pomo, **{f'{name}.pomo': value for name, value in config.pomodoros.items()}}
|
|
167
|
+
for name, routine in sorted(routines.items()):
|
|
168
|
+
write(f'{name}: {routine.rounds} focus rounds · focus {format_duration(routine.focus)}'
|
|
169
|
+
f' · short break {format_duration(routine.short_break)}'
|
|
170
|
+
f' · long break {format_duration(routine.long_break)}'
|
|
171
|
+
+ ('' if routine.auto_start else ' · waits between phases'))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def preview(name: str, target: Pomodoro | int) -> None:
|
|
175
|
+
if isinstance(target, Pomodoro):
|
|
176
|
+
total = 0
|
|
177
|
+
for phase, duration, round_number in target.phases():
|
|
178
|
+
write(f'{name} · {phase} {round_number}/{target.rounds}: {format_duration(duration)}')
|
|
179
|
+
total += duration
|
|
180
|
+
else:
|
|
181
|
+
total = target
|
|
182
|
+
write(f'{name}: {format_duration(target)}')
|
|
183
|
+
write(f'Total: {format_duration(total)}')
|
|
184
|
+
waits = isinstance(target, Pomodoro) and not target.auto_start
|
|
185
|
+
write(f"Ends: {end_time(total)} if started now{', plus time waiting between phases' if waits else ''}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def hook_runner(args: argparse.Namespace, config: Config) -> HookRunner | None:
|
|
189
|
+
"""Configured hooks (unless --no-hooks) plus --hook commands, which run on every event."""
|
|
190
|
+
commands = {} if args.no_hooks else dict(config.hooks)
|
|
191
|
+
if args.hook:
|
|
192
|
+
commands['any'] = commands.get('any', []) + args.hook
|
|
193
|
+
return HookRunner(commands, args.timer) if commands else None
|
pypomo/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Optional TOML or JSON configuration."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .durations import DEFAULT_UNITS, UNITS_MESSAGE, parse_duration
|
|
10
|
+
from .hooks import Command, parse_hooks
|
|
11
|
+
from .pomodoro import Pomodoro, parse_pomodoro
|
|
12
|
+
from .pretty import Pretty, parse_pretty
|
|
13
|
+
|
|
14
|
+
NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_-]*')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Config:
|
|
19
|
+
default_units: str | None = None
|
|
20
|
+
timers: dict[str, int] = field(default_factory=dict)
|
|
21
|
+
pretty: Pretty = field(default_factory=Pretty)
|
|
22
|
+
pomo: Pomodoro = field(default_factory=Pomodoro)
|
|
23
|
+
pomodoros: dict[str, Pomodoro] = field(default_factory=dict)
|
|
24
|
+
hooks: dict[str, list[Command]] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_config_path() -> Path:
|
|
28
|
+
xdg = os.environ.get('XDG_CONFIG_HOME')
|
|
29
|
+
base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path.home() / '.config'
|
|
30
|
+
directory = base / 'pypomo'
|
|
31
|
+
toml = directory / 'config.toml'
|
|
32
|
+
legacy = directory / 'config.json'
|
|
33
|
+
return toml if toml.exists() or not legacy.exists() else legacy
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_config(path: Path, *, required: bool = False) -> Config:
|
|
37
|
+
config = Config()
|
|
38
|
+
try:
|
|
39
|
+
content = path.read_text(encoding='utf-8-sig')
|
|
40
|
+
if path.suffix.lower() == '.toml':
|
|
41
|
+
data = tomllib.loads(content)
|
|
42
|
+
elif path.suffix.lower() == '.json':
|
|
43
|
+
data = json.loads(content)
|
|
44
|
+
else:
|
|
45
|
+
raise ValueError('Config file must use .toml or .json.')
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
if not required:
|
|
48
|
+
return config
|
|
49
|
+
raise ValueError(f'Config file not found: {path}') from None
|
|
50
|
+
except (OSError, ValueError) as exc:
|
|
51
|
+
raise ValueError(f'Cannot read config {path}: {exc}') from exc
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise ValueError('Config must be an object/table.')
|
|
54
|
+
unknown = data.keys() - {'default_units', 'timers', 'pretty', 'pomo', 'pomodoros', 'hooks'}
|
|
55
|
+
if unknown:
|
|
56
|
+
raise ValueError(f"Unknown config settings: {', '.join(sorted(unknown))}")
|
|
57
|
+
if 'default_units' in data:
|
|
58
|
+
unit = data['default_units']
|
|
59
|
+
if not isinstance(unit, str) or unit not in DEFAULT_UNITS:
|
|
60
|
+
raise ValueError(UNITS_MESSAGE)
|
|
61
|
+
config.default_units = unit
|
|
62
|
+
config.pretty = parse_pretty(data.get('pretty', {}))
|
|
63
|
+
config.hooks = parse_hooks(data.get('hooks', {}))
|
|
64
|
+
timers = data.get('timers', {})
|
|
65
|
+
if not isinstance(timers, dict):
|
|
66
|
+
raise ValueError('timers must map names to duration strings.')
|
|
67
|
+
for name, duration in timers.items():
|
|
68
|
+
if not NAME_RE.fullmatch(name):
|
|
69
|
+
raise ValueError(f'Invalid timer name {name!r}: start with a letter; use letters, digits, _ or -.')
|
|
70
|
+
if not isinstance(duration, str):
|
|
71
|
+
raise ValueError(f'Timer {name!r} must contain a duration string.')
|
|
72
|
+
try:
|
|
73
|
+
config.timers[name] = parse_duration(duration, config.default_units)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise ValueError(f'Timer {name!r}: {exc}') from exc
|
|
76
|
+
legacy_focus = config.timers.pop('pomo', config.pomo.focus)
|
|
77
|
+
config.pomo = parse_pomodoro(data.get('pomo', {}), Pomodoro(focus=legacy_focus),
|
|
78
|
+
config.default_units, 'pomo')
|
|
79
|
+
routines = data.get('pomodoros', {})
|
|
80
|
+
if not isinstance(routines, dict):
|
|
81
|
+
raise ValueError('pomodoros must map names to routine settings.')
|
|
82
|
+
for name, settings in routines.items():
|
|
83
|
+
if not NAME_RE.fullmatch(name):
|
|
84
|
+
raise ValueError(f'Invalid Pomodoro name {name!r}: start with a letter; use letters, digits, _ or -.')
|
|
85
|
+
config.pomodoros[name] = parse_pomodoro(settings, config.pomo, config.default_units,
|
|
86
|
+
f'pomodoros.{name}')
|
|
87
|
+
return config
|
pypomo/durations.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Duration parsing shared by CLI and configuration."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
UNITS = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
|
|
5
|
+
MAX_SECONDS = 365 * 86400
|
|
6
|
+
DURATION_RE = re.compile(r'(?:[0-9]+[dhms])+')
|
|
7
|
+
SPACED_RE = re.compile(r'(?:[0-9]+[dhms])+(?: +(?:[0-9]+[dhms])+)+')
|
|
8
|
+
DEFAULT_UNITS = {'s': 's', 'seconds': 's', 'm': 'm', 'minutes': 'm', 'h': 'h', 'hours': 'h',
|
|
9
|
+
'd': 'd', 'days': 'd'}
|
|
10
|
+
UNITS_MESSAGE = 'default_units must be seconds, minutes, hours, days, s, m, h, or d.'
|
|
11
|
+
SPACED_MESSAGE = 'Spaces are allowed only between units, as in 1h 30m.'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_duration(value: str, default_units: str | None = None) -> int:
|
|
15
|
+
if any(char.isspace() for char in value):
|
|
16
|
+
if not SPACED_RE.fullmatch(value):
|
|
17
|
+
raise ValueError(SPACED_MESSAGE)
|
|
18
|
+
value = value.replace(' ', '')
|
|
19
|
+
if re.fullmatch(r'[0-9]+', value):
|
|
20
|
+
if default_units is None:
|
|
21
|
+
raise ValueError('Missing units: use 15s, 15m, 15h, or 15d, or configure default_units.')
|
|
22
|
+
if default_units not in DEFAULT_UNITS:
|
|
23
|
+
raise ValueError(UNITS_MESSAGE)
|
|
24
|
+
value += DEFAULT_UNITS[default_units]
|
|
25
|
+
if not DURATION_RE.fullmatch(value):
|
|
26
|
+
raise ValueError('Invalid duration: use whole numbers with d, h, m, or s (e.g. 1h30m15s).')
|
|
27
|
+
total, previous = 0, UNITS['d'] + 1
|
|
28
|
+
for amount, unit in re.findall(r'([0-9]+)([dhms])', value):
|
|
29
|
+
multiplier = UNITS[unit]
|
|
30
|
+
if multiplier >= previous:
|
|
31
|
+
raise ValueError('Duration units must appear once each, in order: d, h, m, s.')
|
|
32
|
+
amount = amount.lstrip('0') or '0'
|
|
33
|
+
if len(amount) > len(str(MAX_SECONDS)):
|
|
34
|
+
raise ValueError('Duration too long: maximum is 365 days (365d).')
|
|
35
|
+
total += int(amount) * multiplier
|
|
36
|
+
if total > MAX_SECONDS:
|
|
37
|
+
raise ValueError('Duration too long: maximum is 365 days (365d).')
|
|
38
|
+
previous = multiplier
|
|
39
|
+
if total <= 0:
|
|
40
|
+
raise ValueError('Duration must be greater than zero.')
|
|
41
|
+
return total
|
pypomo/events.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Timer events and the listeners that receive them (hooks and JSON lines)."""
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .output import write
|
|
8
|
+
|
|
9
|
+
EVENTS = ('start', 'end', 'pause', 'resume', 'cancel', 'wait')
|
|
10
|
+
|
|
11
|
+
# Called by a running timer with an event name and the whole seconds remaining.
|
|
12
|
+
EventHandler = Callable[[str, int], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Phase:
|
|
17
|
+
"""What listeners are told about the timer or routine phase behind an event."""
|
|
18
|
+
label: str
|
|
19
|
+
phase: str
|
|
20
|
+
duration: int
|
|
21
|
+
round: int | None = None
|
|
22
|
+
rounds: int | None = None
|
|
23
|
+
last: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Receives the event name, its phase, and the whole seconds remaining.
|
|
27
|
+
Listener = Callable[[str, Phase, int], None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Events:
|
|
31
|
+
"""Fans each timer event out to every listener."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, listeners: Sequence[Listener]):
|
|
34
|
+
self.listeners = list(listeners)
|
|
35
|
+
|
|
36
|
+
def __bool__(self) -> bool:
|
|
37
|
+
return bool(self.listeners)
|
|
38
|
+
|
|
39
|
+
def handler(self, phase: Phase) -> EventHandler:
|
|
40
|
+
def emit(event: str, remaining: int) -> None:
|
|
41
|
+
for listener in self.listeners:
|
|
42
|
+
listener(event, phase, remaining)
|
|
43
|
+
return emit
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def event_record(event: str, timer: str, phase: Phase, remaining: int,
|
|
47
|
+
now: datetime | None = None) -> dict[str, object]:
|
|
48
|
+
return {
|
|
49
|
+
'event': event,
|
|
50
|
+
'timer': timer,
|
|
51
|
+
'label': phase.label,
|
|
52
|
+
'phase': phase.phase,
|
|
53
|
+
'round': phase.round,
|
|
54
|
+
'rounds': phase.rounds,
|
|
55
|
+
'duration': phase.duration,
|
|
56
|
+
'remaining': remaining,
|
|
57
|
+
'last': phase.last,
|
|
58
|
+
'time': (now or datetime.now().astimezone()).isoformat(timespec='seconds'),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class JsonLines:
|
|
63
|
+
"""Writes one ASCII JSON object per event to stdout, flushed immediately."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, timer: str):
|
|
66
|
+
self.timer = timer
|
|
67
|
+
|
|
68
|
+
def __call__(self, event: str, phase: Phase, remaining: int) -> None:
|
|
69
|
+
write(json.dumps(event_record(event, self.timer, phase, remaining)))
|
pypomo/hooks.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Run user commands on timer events, without waiting for them."""
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from .events import EVENTS, Phase
|
|
8
|
+
|
|
9
|
+
HOOK_KEYS = (*EVENTS, 'any')
|
|
10
|
+
|
|
11
|
+
Command = str | list[str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_hooks(data: object) -> dict[str, list[Command]]:
|
|
15
|
+
"""Validate a [hooks] table: event name -> shell string or argv list."""
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
raise ValueError('hooks must be an object/table.')
|
|
18
|
+
unknown = data.keys() - set(HOOK_KEYS)
|
|
19
|
+
if unknown:
|
|
20
|
+
raise ValueError(f"Unknown hooks: {', '.join(sorted(unknown))}. "
|
|
21
|
+
f"Use {', '.join(HOOK_KEYS)}.")
|
|
22
|
+
result: dict[str, list[Command]] = {}
|
|
23
|
+
for event, command in data.items():
|
|
24
|
+
if isinstance(command, str) and command.strip():
|
|
25
|
+
result[event] = [command]
|
|
26
|
+
elif (isinstance(command, list) and command
|
|
27
|
+
and all(isinstance(part, str) for part in command) and command[0]):
|
|
28
|
+
result[event] = [list(command)]
|
|
29
|
+
else:
|
|
30
|
+
raise ValueError(f'hooks.{event} must be a command string or a nonempty list of '
|
|
31
|
+
f'strings; got {command!r}.')
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HookRunner:
|
|
36
|
+
"""Starts configured commands in the background and never lets them stop the timer.
|
|
37
|
+
|
|
38
|
+
String commands run through the system shell; lists run directly. Commands
|
|
39
|
+
get PYPOMO_* environment variables, no stdin, and discarded stdout; stderr is
|
|
40
|
+
inherited so their errors stay visible. Each runs in its own session, so
|
|
41
|
+
Ctrl+C or a closing terminal does not interrupt a hook already running.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, commands: Mapping[str, list[Command]], timer: str):
|
|
45
|
+
self.commands = {event: list(items) for event, items in commands.items() if items}
|
|
46
|
+
self.timer = timer
|
|
47
|
+
self.running: list[subprocess.Popen[bytes]] = []
|
|
48
|
+
self.failed: set[str] = set()
|
|
49
|
+
|
|
50
|
+
def __bool__(self) -> bool:
|
|
51
|
+
return bool(self.commands)
|
|
52
|
+
|
|
53
|
+
def __call__(self, event: str, phase: Phase, remaining: int) -> None:
|
|
54
|
+
self.emit(event, phase, remaining)
|
|
55
|
+
|
|
56
|
+
def emit(self, event: str, phase: Phase, remaining: int) -> None:
|
|
57
|
+
commands = self.commands.get(event, []) + self.commands.get('any', [])
|
|
58
|
+
if not commands:
|
|
59
|
+
return
|
|
60
|
+
env = dict(os.environ,
|
|
61
|
+
PYPOMO_EVENT=event,
|
|
62
|
+
PYPOMO_TIMER=self.timer,
|
|
63
|
+
PYPOMO_LABEL=phase.label,
|
|
64
|
+
PYPOMO_PHASE=phase.phase,
|
|
65
|
+
PYPOMO_ROUND='' if phase.round is None else str(phase.round),
|
|
66
|
+
PYPOMO_ROUNDS='' if phase.rounds is None else str(phase.rounds),
|
|
67
|
+
PYPOMO_DURATION=str(phase.duration),
|
|
68
|
+
PYPOMO_REMAINING=str(remaining),
|
|
69
|
+
PYPOMO_LAST='1' if phase.last else '0')
|
|
70
|
+
self.running = [process for process in self.running if process.poll() is None]
|
|
71
|
+
for command in commands:
|
|
72
|
+
self._start(command, env)
|
|
73
|
+
|
|
74
|
+
def _start(self, command: Command, env: dict[str, str]) -> None:
|
|
75
|
+
options: dict[str, object] = {}
|
|
76
|
+
if sys.platform == 'win32':
|
|
77
|
+
options['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
78
|
+
else:
|
|
79
|
+
options['start_new_session'] = True
|
|
80
|
+
try:
|
|
81
|
+
self.running.append(subprocess.Popen(
|
|
82
|
+
command, shell=isinstance(command, str), env=env,
|
|
83
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, **options))
|
|
84
|
+
except (OSError, ValueError) as exc:
|
|
85
|
+
name = command if isinstance(command, str) else ' '.join(command)
|
|
86
|
+
if name not in self.failed: # Warn once per command, not on every event.
|
|
87
|
+
self.failed.add(name)
|
|
88
|
+
try:
|
|
89
|
+
print(f'\npypomo: hook could not start: {name}: {exc}', file=sys.stderr, flush=True)
|
|
90
|
+
except (OSError, ValueError, AttributeError):
|
|
91
|
+
pass
|
pypomo/keys.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Single-key terminal input for pause/resume and phase confirmation."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
PAUSE_KEYS = frozenset('p ')
|
|
10
|
+
QUIT_KEYS = frozenset('q')
|
|
11
|
+
ENTER_KEYS = frozenset('\r\n')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Keyboard:
|
|
15
|
+
"""Reads keys without waiting for Enter. Created only by `keyboard()`."""
|
|
16
|
+
|
|
17
|
+
def read(self, timeout: float | None) -> str | None:
|
|
18
|
+
"""Return one keypress, or None when `timeout` seconds pass first.
|
|
19
|
+
|
|
20
|
+
Escape sequences (arrow and function keys) are discarded.
|
|
21
|
+
"""
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _PosixKeyboard(Keyboard):
|
|
26
|
+
def __init__(self, fd: int):
|
|
27
|
+
self.fd = fd
|
|
28
|
+
|
|
29
|
+
def read(self, timeout: float | None) -> str | None:
|
|
30
|
+
import select
|
|
31
|
+
|
|
32
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
33
|
+
while True:
|
|
34
|
+
wait = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
35
|
+
ready, _, _ = select.select([self.fd], [], [], wait)
|
|
36
|
+
if not ready:
|
|
37
|
+
return None
|
|
38
|
+
data = os.read(self.fd, 64).decode(errors='ignore')
|
|
39
|
+
if not data: # Terminal hung up; behave as if no key will ever arrive.
|
|
40
|
+
if timeout is not None:
|
|
41
|
+
time.sleep(wait or 0)
|
|
42
|
+
return None
|
|
43
|
+
raise KeyboardInterrupt
|
|
44
|
+
if not data.startswith('\x1b'):
|
|
45
|
+
return data[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _WindowsKeyboard(Keyboard):
|
|
49
|
+
def read(self, timeout: float | None) -> str | None:
|
|
50
|
+
import msvcrt
|
|
51
|
+
|
|
52
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
53
|
+
while True:
|
|
54
|
+
while msvcrt.kbhit():
|
|
55
|
+
key = msvcrt.getwch()
|
|
56
|
+
if key in ('\x00', '\xe0'): # Prefix of an arrow or function key.
|
|
57
|
+
msvcrt.getwch()
|
|
58
|
+
continue
|
|
59
|
+
if key == '\x03':
|
|
60
|
+
raise KeyboardInterrupt
|
|
61
|
+
return key
|
|
62
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
63
|
+
return None
|
|
64
|
+
remaining = 0.05 if deadline is None else deadline - time.monotonic()
|
|
65
|
+
time.sleep(max(0.0, min(0.05, remaining)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _foreground_tty(stream: TextIO) -> int | None:
|
|
69
|
+
try:
|
|
70
|
+
fd = stream.fileno()
|
|
71
|
+
if not os.isatty(fd):
|
|
72
|
+
return None
|
|
73
|
+
except (AttributeError, OSError, ValueError):
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
# A background job that changes terminal modes is stopped with SIGTTOU.
|
|
77
|
+
if os.tcgetpgrp(fd) != os.getpgrp():
|
|
78
|
+
return None
|
|
79
|
+
except (AttributeError, OSError):
|
|
80
|
+
pass # Windows, or a terminal that is not our controlling terminal.
|
|
81
|
+
return fd
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def keyboard() -> Iterator[Keyboard | None]:
|
|
86
|
+
"""Yield a Keyboard when stdin is a foreground terminal, else None.
|
|
87
|
+
|
|
88
|
+
On POSIX the terminal is switched to cbreak mode (no line buffering, no
|
|
89
|
+
echo) and restored on exit. Ctrl+C and Ctrl+Z keep their usual meaning.
|
|
90
|
+
"""
|
|
91
|
+
fd = None if sys.stdin is None else _foreground_tty(sys.stdin)
|
|
92
|
+
if fd is None:
|
|
93
|
+
yield None
|
|
94
|
+
return
|
|
95
|
+
if sys.platform == 'win32':
|
|
96
|
+
yield _WindowsKeyboard()
|
|
97
|
+
return
|
|
98
|
+
import signal
|
|
99
|
+
import termios
|
|
100
|
+
import threading
|
|
101
|
+
import tty
|
|
102
|
+
|
|
103
|
+
def cbreak(*_):
|
|
104
|
+
try:
|
|
105
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
106
|
+
except termios.error:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
saved = termios.tcgetattr(fd)
|
|
111
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
112
|
+
except termios.error:
|
|
113
|
+
yield None
|
|
114
|
+
return
|
|
115
|
+
previous = None
|
|
116
|
+
if threading.current_thread() is threading.main_thread():
|
|
117
|
+
# The shell may restore its own terminal modes after Ctrl+Z; reapply ours on `fg`.
|
|
118
|
+
previous = signal.signal(signal.SIGCONT, cbreak)
|
|
119
|
+
try:
|
|
120
|
+
yield _PosixKeyboard(fd)
|
|
121
|
+
finally:
|
|
122
|
+
if previous is not None:
|
|
123
|
+
signal.signal(signal.SIGCONT, previous)
|
|
124
|
+
try:
|
|
125
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
126
|
+
except termios.error:
|
|
127
|
+
pass
|
pypomo/output.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Small helpers shared by timer and CLI output."""
|
|
2
|
+
import errno
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def safe_text(text: str, fallback: str | None = None) -> str:
|
|
10
|
+
encoding = getattr(sys.stdout, 'encoding', None) or 'utf-8'
|
|
11
|
+
try:
|
|
12
|
+
text.encode(encoding)
|
|
13
|
+
return text
|
|
14
|
+
except UnicodeEncodeError:
|
|
15
|
+
if fallback is not None:
|
|
16
|
+
return fallback
|
|
17
|
+
return text.replace('·', '-').encode(encoding, errors='backslashreplace').decode(encoding)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@contextmanager
|
|
21
|
+
def _pipe_errors():
|
|
22
|
+
"""Normalize the Windows CRT's EINVAL for writes to a broken pipe only."""
|
|
23
|
+
try:
|
|
24
|
+
yield
|
|
25
|
+
except OSError as exc:
|
|
26
|
+
if sys.platform == 'win32' and exc.errno == errno.EINVAL:
|
|
27
|
+
try:
|
|
28
|
+
is_pipe = stat.S_ISFIFO(os.fstat(sys.stdout.fileno()).st_mode)
|
|
29
|
+
except (AttributeError, OSError, ValueError):
|
|
30
|
+
is_pipe = False
|
|
31
|
+
if is_pipe:
|
|
32
|
+
raise BrokenPipeError(errno.EPIPE, 'Broken output pipe') from exc
|
|
33
|
+
raise
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write(text: str, *, end: str = '\n') -> None:
|
|
37
|
+
if sys.stdout is not None:
|
|
38
|
+
with _pipe_errors():
|
|
39
|
+
print(safe_text(text), end=end, flush=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def flush() -> None:
|
|
43
|
+
if sys.stdout is not None:
|
|
44
|
+
with _pipe_errors():
|
|
45
|
+
sys.stdout.flush()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def silence_broken_pipe() -> None:
|
|
49
|
+
"""Prevent a second broken-pipe error during interpreter shutdown."""
|
|
50
|
+
try:
|
|
51
|
+
descriptor = sys.stdout.fileno()
|
|
52
|
+
except (AttributeError, OSError, ValueError):
|
|
53
|
+
return
|
|
54
|
+
with open(os.devnull, 'w') as sink:
|
|
55
|
+
os.dup2(sink.fileno(), descriptor)
|