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 ADDED
@@ -0,0 +1,4 @@
1
+ """A small terminal timer."""
2
+ from .cli import main
3
+
4
+ __all__ = ['main']
pypomo/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)