zen-timer 0.2.0__tar.gz
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.
- zen_timer-0.2.0/LICENSE +21 -0
- zen_timer-0.2.0/PKG-INFO +124 -0
- zen_timer-0.2.0/README.md +88 -0
- zen_timer-0.2.0/pyproject.toml +65 -0
- zen_timer-0.2.0/setup.cfg +4 -0
- zen_timer-0.2.0/src/zen/__init__.py +1 -0
- zen_timer-0.2.0/src/zen/__main__.py +11 -0
- zen_timer-0.2.0/src/zen/timer.py +174 -0
- zen_timer-0.2.0/src/zen_timer.egg-info/PKG-INFO +124 -0
- zen_timer-0.2.0/src/zen_timer.egg-info/SOURCES.txt +13 -0
- zen_timer-0.2.0/src/zen_timer.egg-info/dependency_links.txt +1 -0
- zen_timer-0.2.0/src/zen_timer.egg-info/entry_points.txt +2 -0
- zen_timer-0.2.0/src/zen_timer.egg-info/requires.txt +9 -0
- zen_timer-0.2.0/src/zen_timer.egg-info/top_level.txt +1 -0
- zen_timer-0.2.0/tests/test_timer.py +279 -0
zen_timer-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shenald-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
zen_timer-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zen-timer
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: 🧘 Deep-work timer. Simple terminal app for focus sessions. No GUI, just flow.
|
|
5
|
+
Author-email: Shenal Diluksha <shenald@users.noreply.github.com>
|
|
6
|
+
Maintainer-email: shenald-dev <shenald@users.noreply.github.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/shenald-dev/zen
|
|
9
|
+
Project-URL: Documentation, https://github.com/shenald-dev/zen#readme
|
|
10
|
+
Project-URL: Repository, https://github.com/shenald-dev/zen
|
|
11
|
+
Project-URL: Issues, https://github.com/shenald-dev/zen/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/shenald-dev/zen/blob/main/CHANGELOG.md
|
|
13
|
+
Keywords: zen,timer,pomodoro,focus,deep-work,cli,tui,productivity
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: rich<16.0.0,>=13.9.0
|
|
28
|
+
Requires-Dist: typer<1.0.0,>=0.12.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest<10.0.0,>=8.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.14.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov<8.0.0,>=5.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: flake8<8.0.0,>=7.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pylint<5.0.0,>=3.0.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# README.md
|
|
38
|
+
<div align="center">
|
|
39
|
+
<img src="assets/logo.png" alt="zen Logo" width="250" />
|
|
40
|
+
|
|
41
|
+
<br/>
|
|
42
|
+
|
|
43
|
+
<h1>🧘♂️ zen</h1>
|
|
44
|
+
<p><b>Minimalist Terminal Deep-Work Timer</b></p>
|
|
45
|
+
<i>Simple terminal app for focus sessions. No GUI, just flow.</i>
|
|
46
|
+
|
|
47
|
+
<br/>
|
|
48
|
+
|
|
49
|
+
[](https://pypi.org/project/zen-timer/)
|
|
50
|
+
[](https://python.org)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 📑 Table of Contents
|
|
57
|
+
- [🌟 Overview](#-overview)
|
|
58
|
+
- [🚀 Enterprise Features](#-enterprise-features)
|
|
59
|
+
- [⚡ Quick Start Guide](#-quick-start-guide)
|
|
60
|
+
- [💻 Comprehensive Usage](#-comprehensive-usage)
|
|
61
|
+
- [🤝 Contributing](#-contributing)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 🌟 Overview
|
|
66
|
+
|
|
67
|
+
**zen** is an ultra-minimalist, distraction-free terminal application built to safeguard developer focus. It ditches complex GUIs in favor of beautiful, native terminal progress indicators. Dive straight into flow state.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 🚀 Enterprise Features
|
|
72
|
+
|
|
73
|
+
- **🌊 Flow State**: Visually stunning, distraction-free TUI progress bar.
|
|
74
|
+
- **⏱️ Flexible**: Fully configurable focus sessions and break reminders.
|
|
75
|
+
- **🎨 Premium Aesthetic**: Powered by `Rich` and `Typer` to look incredible in any modern terminal emulator (iTerm, Kitty, Windows Terminal).
|
|
76
|
+
- **🚀 Performance**: Lightning-fast startup with lazy-loaded imports and zero-overhead UI rendering.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## ⚡ Quick Start Guide
|
|
81
|
+
|
|
82
|
+
### Installation
|
|
83
|
+
|
|
84
|
+
Install via pip:
|
|
85
|
+
```bash
|
|
86
|
+
pip install zen-timer
|
|
87
|
+
```
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 💻 Comprehensive Usage
|
|
91
|
+
|
|
92
|
+
### Start a Session
|
|
93
|
+
To start a default 25-minute Pomodoro session:
|
|
94
|
+
```bash
|
|
95
|
+
zen
|
|
96
|
+
```
|
|
97
|
+
### Advanced Usage
|
|
98
|
+
|
|
99
|
+
**zen** supports additional CLI flags to customize your workflow:
|
|
100
|
+
|
|
101
|
+
- **`--break`**: Run the timer as a break session instead of a focus session. Ideal for timing your rest periods between deep work blocks.
|
|
102
|
+
```bash
|
|
103
|
+
zen 5 --break
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **`--silent`**: Run the timer without sending desktop notifications upon completion. Perfect for environments where notifications are distracting or unsupported.
|
|
107
|
+
```bash
|
|
108
|
+
zen 25 --silent
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can also combine these flags for a silent break session:
|
|
112
|
+
```bash
|
|
113
|
+
zen 15 --break --silent
|
|
114
|
+
```
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 🤝 Contributing
|
|
118
|
+
Want to help safeguard developer focus? Let's flow together! 🌊
|
|
119
|
+
- 🐛 **Found a bug?** Open an issue to let us know.
|
|
120
|
+
- ✨ **Have a feature idea?** We are open to PRs! Keep the UI minimal and the code beautiful.
|
|
121
|
+
- 🎨 **Documentation tweaks?** Always welcome!
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
> *Crafted by a Vibe Coder. Protect your focus.*
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# README.md
|
|
2
|
+
<div align="center">
|
|
3
|
+
<img src="assets/logo.png" alt="zen Logo" width="250" />
|
|
4
|
+
|
|
5
|
+
<br/>
|
|
6
|
+
|
|
7
|
+
<h1>🧘♂️ zen</h1>
|
|
8
|
+
<p><b>Minimalist Terminal Deep-Work Timer</b></p>
|
|
9
|
+
<i>Simple terminal app for focus sessions. No GUI, just flow.</i>
|
|
10
|
+
|
|
11
|
+
<br/>
|
|
12
|
+
|
|
13
|
+
[](https://pypi.org/project/zen-timer/)
|
|
14
|
+
[](https://python.org)
|
|
15
|
+
[](LICENSE)
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 📑 Table of Contents
|
|
21
|
+
- [🌟 Overview](#-overview)
|
|
22
|
+
- [🚀 Enterprise Features](#-enterprise-features)
|
|
23
|
+
- [⚡ Quick Start Guide](#-quick-start-guide)
|
|
24
|
+
- [💻 Comprehensive Usage](#-comprehensive-usage)
|
|
25
|
+
- [🤝 Contributing](#-contributing)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 🌟 Overview
|
|
30
|
+
|
|
31
|
+
**zen** is an ultra-minimalist, distraction-free terminal application built to safeguard developer focus. It ditches complex GUIs in favor of beautiful, native terminal progress indicators. Dive straight into flow state.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 🚀 Enterprise Features
|
|
36
|
+
|
|
37
|
+
- **🌊 Flow State**: Visually stunning, distraction-free TUI progress bar.
|
|
38
|
+
- **⏱️ Flexible**: Fully configurable focus sessions and break reminders.
|
|
39
|
+
- **🎨 Premium Aesthetic**: Powered by `Rich` and `Typer` to look incredible in any modern terminal emulator (iTerm, Kitty, Windows Terminal).
|
|
40
|
+
- **🚀 Performance**: Lightning-fast startup with lazy-loaded imports and zero-overhead UI rendering.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## ⚡ Quick Start Guide
|
|
45
|
+
|
|
46
|
+
### Installation
|
|
47
|
+
|
|
48
|
+
Install via pip:
|
|
49
|
+
```bash
|
|
50
|
+
pip install zen-timer
|
|
51
|
+
```
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 💻 Comprehensive Usage
|
|
55
|
+
|
|
56
|
+
### Start a Session
|
|
57
|
+
To start a default 25-minute Pomodoro session:
|
|
58
|
+
```bash
|
|
59
|
+
zen
|
|
60
|
+
```
|
|
61
|
+
### Advanced Usage
|
|
62
|
+
|
|
63
|
+
**zen** supports additional CLI flags to customize your workflow:
|
|
64
|
+
|
|
65
|
+
- **`--break`**: Run the timer as a break session instead of a focus session. Ideal for timing your rest periods between deep work blocks.
|
|
66
|
+
```bash
|
|
67
|
+
zen 5 --break
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- **`--silent`**: Run the timer without sending desktop notifications upon completion. Perfect for environments where notifications are distracting or unsupported.
|
|
71
|
+
```bash
|
|
72
|
+
zen 25 --silent
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can also combine these flags for a silent break session:
|
|
76
|
+
```bash
|
|
77
|
+
zen 15 --break --silent
|
|
78
|
+
```
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🤝 Contributing
|
|
82
|
+
Want to help safeguard developer focus? Let's flow together! 🌊
|
|
83
|
+
- 🐛 **Found a bug?** Open an issue to let us know.
|
|
84
|
+
- ✨ **Have a feature idea?** We are open to PRs! Keep the UI minimal and the code beautiful.
|
|
85
|
+
- 🎨 **Documentation tweaks?** Always welcome!
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
> *Crafted by a Vibe Coder. Protect your focus.*
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zen-timer"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "🧘 Deep-work timer. Simple terminal app for focus sessions. No GUI, just flow."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Shenal Diluksha", email = "shenald@users.noreply.github.com" }
|
|
9
|
+
]
|
|
10
|
+
maintainers = [
|
|
11
|
+
{ name = "shenald-dev", email = "shenald@users.noreply.github.com" }
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = ["zen", "timer", "pomodoro", "focus", "deep-work", "cli", "tui", "productivity"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Utilities"
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"rich>=13.9.0,<16.0.0",
|
|
29
|
+
"typer>=0.12.0,<1.0.0"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0.0,<10.0.0",
|
|
35
|
+
"pytest-mock>=3.14.0,<4.0.0",
|
|
36
|
+
"pytest-cov>=5.0.0,<8.0.0",
|
|
37
|
+
"flake8>=7.0.0,<8.0.0",
|
|
38
|
+
"pylint>=3.0.0,<5.0.0"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
zen = "zen.timer:main"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/shenald-dev/zen"
|
|
46
|
+
Documentation = "https://github.com/shenald-dev/zen#readme"
|
|
47
|
+
Repository = "https://github.com/shenald-dev/zen"
|
|
48
|
+
Issues = "https://github.com/shenald-dev/zen/issues"
|
|
49
|
+
Changelog = "https://github.com/shenald-dev/zen/blob/main/CHANGELOG.md"
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["setuptools>=61.0"]
|
|
53
|
+
build-backend = "setuptools.build_meta"
|
|
54
|
+
|
|
55
|
+
[tool.coverage.run]
|
|
56
|
+
omit = [
|
|
57
|
+
"src/zen/__main__.py",
|
|
58
|
+
"src/zen/__init__.py"
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["src"]
|
|
63
|
+
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Zen - Deep-work terminal timer."""
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Zen - Deep-work terminal timer."""
|
|
2
|
+
import time
|
|
3
|
+
import typer
|
|
4
|
+
# pylint: disable=import-outside-toplevel
|
|
5
|
+
# Import rich lazily inside the command for faster --help execution
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
help="🧘 Deep-work terminal timer for focus sessions.",
|
|
9
|
+
add_completion=False,
|
|
10
|
+
rich_markup_mode=None,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def version_callback(value: bool) -> None:
|
|
15
|
+
"""Callback for the --version option."""
|
|
16
|
+
if value:
|
|
17
|
+
import importlib.metadata # pylint: disable=import-outside-toplevel
|
|
18
|
+
try:
|
|
19
|
+
version = importlib.metadata.version("zen-timer")
|
|
20
|
+
except importlib.metadata.PackageNotFoundError:
|
|
21
|
+
version = "unknown"
|
|
22
|
+
print(f"zen-timer version {version}")
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command() # type: ignore[untyped-decorator]
|
|
27
|
+
def focus( # pylint: disable=too-many-statements,too-many-branches
|
|
28
|
+
minutes: int = typer.Argument(
|
|
29
|
+
25, min=1, max=1440, help="Minutes to focus for"
|
|
30
|
+
),
|
|
31
|
+
is_break: bool = typer.Option(
|
|
32
|
+
False, "--break", help="Start a break session."
|
|
33
|
+
),
|
|
34
|
+
silent: bool = typer.Option(
|
|
35
|
+
False, "--silent", help="Run without desktop notifications."
|
|
36
|
+
),
|
|
37
|
+
version: bool = typer.Option( # pylint: disable=unused-argument
|
|
38
|
+
None,
|
|
39
|
+
"--version",
|
|
40
|
+
callback=version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
help="Show the application version and exit."
|
|
43
|
+
)
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Start a deep-work focus session."""
|
|
46
|
+
# pylint: disable=too-many-locals
|
|
47
|
+
seconds = minutes * 60
|
|
48
|
+
console = None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from rich.console import Console
|
|
52
|
+
from rich.progress import (
|
|
53
|
+
Progress, BarColumn, TextColumn, TimeRemainingColumn
|
|
54
|
+
)
|
|
55
|
+
from rich.panel import Panel
|
|
56
|
+
|
|
57
|
+
console = Console()
|
|
58
|
+
console.clear()
|
|
59
|
+
minute_str = "Minute" if minutes == 1 else "Minutes"
|
|
60
|
+
if is_break:
|
|
61
|
+
title_text = (
|
|
62
|
+
f"[bold green]☕ Break Time: {minutes} "
|
|
63
|
+
f"{minute_str}[/bold green]\n"
|
|
64
|
+
"[gray]Rest and recharge.[/gray]"
|
|
65
|
+
)
|
|
66
|
+
border_style = "green"
|
|
67
|
+
task_desc = "[green]Recharging..."
|
|
68
|
+
bar_complete_style = "green"
|
|
69
|
+
else:
|
|
70
|
+
title_text = (
|
|
71
|
+
f"[bold cyan]🧘 Zen Mode Activated: {minutes} "
|
|
72
|
+
f"{minute_str} of Deep Work[/bold cyan]\n"
|
|
73
|
+
"[gray]Do not disturb. No GUI, just flow.[/gray]"
|
|
74
|
+
)
|
|
75
|
+
border_style = "cyan"
|
|
76
|
+
task_desc = "[cyan]Flow State..."
|
|
77
|
+
bar_complete_style = "cyan"
|
|
78
|
+
|
|
79
|
+
title = Panel.fit(
|
|
80
|
+
title_text,
|
|
81
|
+
border_style=border_style,
|
|
82
|
+
padding=(1, 4)
|
|
83
|
+
)
|
|
84
|
+
console.print(title, justify="center")
|
|
85
|
+
console.print("\n")
|
|
86
|
+
|
|
87
|
+
with Progress(
|
|
88
|
+
TextColumn("[progress.description]{task.description}"),
|
|
89
|
+
BarColumn(
|
|
90
|
+
bar_width=None,
|
|
91
|
+
style="magenta",
|
|
92
|
+
complete_style=bar_complete_style,
|
|
93
|
+
finished_style=bar_complete_style
|
|
94
|
+
),
|
|
95
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
96
|
+
TimeRemainingColumn(),
|
|
97
|
+
console=console,
|
|
98
|
+
transient=False,
|
|
99
|
+
auto_refresh=False, # Disable background thread for performance
|
|
100
|
+
) as progress:
|
|
101
|
+
task = progress.add_task(task_desc, total=seconds)
|
|
102
|
+
|
|
103
|
+
start_time = time.monotonic()
|
|
104
|
+
last_second = -1
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
elapsed = time.monotonic() - start_time
|
|
108
|
+
remaining = seconds - elapsed
|
|
109
|
+
|
|
110
|
+
if remaining <= 0:
|
|
111
|
+
progress.update(task, completed=seconds, refresh=True)
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
# Only refresh UI when a full second has passed
|
|
115
|
+
current_second = int(elapsed)
|
|
116
|
+
if current_second > last_second:
|
|
117
|
+
progress.update(
|
|
118
|
+
task, completed=elapsed, refresh=True
|
|
119
|
+
)
|
|
120
|
+
last_second = current_second
|
|
121
|
+
|
|
122
|
+
# Recalculate elapsed to natively absorb loop execution
|
|
123
|
+
# overhead
|
|
124
|
+
elapsed = time.monotonic() - start_time
|
|
125
|
+
remaining = seconds - elapsed
|
|
126
|
+
|
|
127
|
+
# Drift-compensated sleep to maintain exact 1Hz refresh rate
|
|
128
|
+
sleep_interval = 1.0 - (elapsed % 1.0)
|
|
129
|
+
time.sleep(max(0, min(sleep_interval, remaining)))
|
|
130
|
+
|
|
131
|
+
console.print("\n")
|
|
132
|
+
if not silent:
|
|
133
|
+
console.bell()
|
|
134
|
+
|
|
135
|
+
if is_break:
|
|
136
|
+
completion = Panel.fit(
|
|
137
|
+
"[bold cyan]✨ Break complete. Ready to focus?[/bold cyan]",
|
|
138
|
+
border_style="cyan"
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
completion = Panel.fit(
|
|
142
|
+
"[bold green]✨ Focus session complete. "
|
|
143
|
+
"Take a break.[/bold green]",
|
|
144
|
+
border_style="green"
|
|
145
|
+
)
|
|
146
|
+
console.print(completion, justify="center")
|
|
147
|
+
except KeyboardInterrupt as exc:
|
|
148
|
+
if is_break:
|
|
149
|
+
msg = "⏸️ Break paused. Rest is important too."
|
|
150
|
+
else:
|
|
151
|
+
msg = "⏸️ Session paused. Your focus still matters."
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
if console is not None:
|
|
155
|
+
console.print("\n")
|
|
156
|
+
interrupted = Panel.fit(
|
|
157
|
+
f"[bold yellow]{msg}[/bold yellow]",
|
|
158
|
+
border_style="yellow"
|
|
159
|
+
)
|
|
160
|
+
console.print(interrupted, justify="center")
|
|
161
|
+
else:
|
|
162
|
+
print(f"\n{msg}")
|
|
163
|
+
except KeyboardInterrupt:
|
|
164
|
+
pass
|
|
165
|
+
raise typer.Exit(code=130) from exc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> None:
|
|
169
|
+
"""Main entrypoint for the CLI app."""
|
|
170
|
+
app()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__": # pragma: no cover
|
|
174
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zen-timer
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: 🧘 Deep-work timer. Simple terminal app for focus sessions. No GUI, just flow.
|
|
5
|
+
Author-email: Shenal Diluksha <shenald@users.noreply.github.com>
|
|
6
|
+
Maintainer-email: shenald-dev <shenald@users.noreply.github.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/shenald-dev/zen
|
|
9
|
+
Project-URL: Documentation, https://github.com/shenald-dev/zen#readme
|
|
10
|
+
Project-URL: Repository, https://github.com/shenald-dev/zen
|
|
11
|
+
Project-URL: Issues, https://github.com/shenald-dev/zen/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/shenald-dev/zen/blob/main/CHANGELOG.md
|
|
13
|
+
Keywords: zen,timer,pomodoro,focus,deep-work,cli,tui,productivity
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: rich<16.0.0,>=13.9.0
|
|
28
|
+
Requires-Dist: typer<1.0.0,>=0.12.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest<10.0.0,>=8.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.14.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov<8.0.0,>=5.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: flake8<8.0.0,>=7.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pylint<5.0.0,>=3.0.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# README.md
|
|
38
|
+
<div align="center">
|
|
39
|
+
<img src="assets/logo.png" alt="zen Logo" width="250" />
|
|
40
|
+
|
|
41
|
+
<br/>
|
|
42
|
+
|
|
43
|
+
<h1>🧘♂️ zen</h1>
|
|
44
|
+
<p><b>Minimalist Terminal Deep-Work Timer</b></p>
|
|
45
|
+
<i>Simple terminal app for focus sessions. No GUI, just flow.</i>
|
|
46
|
+
|
|
47
|
+
<br/>
|
|
48
|
+
|
|
49
|
+
[](https://pypi.org/project/zen-timer/)
|
|
50
|
+
[](https://python.org)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 📑 Table of Contents
|
|
57
|
+
- [🌟 Overview](#-overview)
|
|
58
|
+
- [🚀 Enterprise Features](#-enterprise-features)
|
|
59
|
+
- [⚡ Quick Start Guide](#-quick-start-guide)
|
|
60
|
+
- [💻 Comprehensive Usage](#-comprehensive-usage)
|
|
61
|
+
- [🤝 Contributing](#-contributing)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 🌟 Overview
|
|
66
|
+
|
|
67
|
+
**zen** is an ultra-minimalist, distraction-free terminal application built to safeguard developer focus. It ditches complex GUIs in favor of beautiful, native terminal progress indicators. Dive straight into flow state.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 🚀 Enterprise Features
|
|
72
|
+
|
|
73
|
+
- **🌊 Flow State**: Visually stunning, distraction-free TUI progress bar.
|
|
74
|
+
- **⏱️ Flexible**: Fully configurable focus sessions and break reminders.
|
|
75
|
+
- **🎨 Premium Aesthetic**: Powered by `Rich` and `Typer` to look incredible in any modern terminal emulator (iTerm, Kitty, Windows Terminal).
|
|
76
|
+
- **🚀 Performance**: Lightning-fast startup with lazy-loaded imports and zero-overhead UI rendering.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## ⚡ Quick Start Guide
|
|
81
|
+
|
|
82
|
+
### Installation
|
|
83
|
+
|
|
84
|
+
Install via pip:
|
|
85
|
+
```bash
|
|
86
|
+
pip install zen-timer
|
|
87
|
+
```
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 💻 Comprehensive Usage
|
|
91
|
+
|
|
92
|
+
### Start a Session
|
|
93
|
+
To start a default 25-minute Pomodoro session:
|
|
94
|
+
```bash
|
|
95
|
+
zen
|
|
96
|
+
```
|
|
97
|
+
### Advanced Usage
|
|
98
|
+
|
|
99
|
+
**zen** supports additional CLI flags to customize your workflow:
|
|
100
|
+
|
|
101
|
+
- **`--break`**: Run the timer as a break session instead of a focus session. Ideal for timing your rest periods between deep work blocks.
|
|
102
|
+
```bash
|
|
103
|
+
zen 5 --break
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **`--silent`**: Run the timer without sending desktop notifications upon completion. Perfect for environments where notifications are distracting or unsupported.
|
|
107
|
+
```bash
|
|
108
|
+
zen 25 --silent
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can also combine these flags for a silent break session:
|
|
112
|
+
```bash
|
|
113
|
+
zen 15 --break --silent
|
|
114
|
+
```
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 🤝 Contributing
|
|
118
|
+
Want to help safeguard developer focus? Let's flow together! 🌊
|
|
119
|
+
- 🐛 **Found a bug?** Open an issue to let us know.
|
|
120
|
+
- ✨ **Have a feature idea?** We are open to PRs! Keep the UI minimal and the code beautiful.
|
|
121
|
+
- 🎨 **Documentation tweaks?** Always welcome!
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
> *Crafted by a Vibe Coder. Protect your focus.*
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/zen/__init__.py
|
|
5
|
+
src/zen/__main__.py
|
|
6
|
+
src/zen/timer.py
|
|
7
|
+
src/zen_timer.egg-info/PKG-INFO
|
|
8
|
+
src/zen_timer.egg-info/SOURCES.txt
|
|
9
|
+
src/zen_timer.egg-info/dependency_links.txt
|
|
10
|
+
src/zen_timer.egg-info/entry_points.txt
|
|
11
|
+
src/zen_timer.egg-info/requires.txt
|
|
12
|
+
src/zen_timer.egg-info/top_level.txt
|
|
13
|
+
tests/test_timer.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zen
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Tests for zen-timer."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pytest_mock import MockerFixture
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from zen.timer import app
|
|
8
|
+
|
|
9
|
+
runner = CliRunner()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MockMonotonic: # pylint: disable=R0903
|
|
13
|
+
"""Mock for time.monotonic."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.current = 0.0
|
|
17
|
+
|
|
18
|
+
def __call__(self) -> float:
|
|
19
|
+
self.current += 10.0
|
|
20
|
+
return self.current
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_focus_invalid_minutes() -> None:
|
|
24
|
+
"""Test that focusing for less than 1 minute raises an error."""
|
|
25
|
+
result = runner.invoke(app, ["0"])
|
|
26
|
+
assert result.exit_code == 2
|
|
27
|
+
assert "0 is not in the range 1<=x<=1440." in result.output
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_focus_invalid_minutes_too_high() -> None:
|
|
31
|
+
"""Test that focusing for more than 1440 minutes raises an error."""
|
|
32
|
+
result = runner.invoke(app, ["1500"])
|
|
33
|
+
assert result.exit_code == 2
|
|
34
|
+
assert "1500 is not in the range 1<=x<=1440." in result.output
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_focus_valid_minutes(mocker: MockerFixture) -> None:
|
|
38
|
+
"""Test a valid focus session."""
|
|
39
|
+
# Mock time.sleep to avoid actually waiting during the test
|
|
40
|
+
mocker.patch("time.sleep")
|
|
41
|
+
|
|
42
|
+
# Mock time.monotonic to instantly advance past the duration
|
|
43
|
+
# We use a generator or function so it doesn't run out of side effects
|
|
44
|
+
# if rich calls it more
|
|
45
|
+
|
|
46
|
+
mock_monotonic = MockMonotonic()
|
|
47
|
+
mocker.patch("time.monotonic", side_effect=mock_monotonic)
|
|
48
|
+
result = runner.invoke(app, ["1"])
|
|
49
|
+
assert result.exit_code == 0
|
|
50
|
+
assert "Zen Mode Activated: 1 Minute of Deep Work" in result.output
|
|
51
|
+
assert "Focus session complete" in result.output
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_focus_valid_minutes_plural(mocker: MockerFixture) -> None:
|
|
55
|
+
"""Test a valid focus session for plural minutes."""
|
|
56
|
+
mocker.patch("time.sleep")
|
|
57
|
+
|
|
58
|
+
mock_monotonic = MockMonotonic()
|
|
59
|
+
mocker.patch("time.monotonic", side_effect=mock_monotonic)
|
|
60
|
+
result = runner.invoke(app, ["2"])
|
|
61
|
+
assert result.exit_code == 0
|
|
62
|
+
assert "Zen Mode Activated: 2 Minutes of Deep Work" in result.output
|
|
63
|
+
assert "Focus session complete" in result.output
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_focus_break_flag(mocker: MockerFixture) -> None:
|
|
67
|
+
"""Test that the --break flag modifies the UI appropriately."""
|
|
68
|
+
mocker.patch("time.sleep")
|
|
69
|
+
|
|
70
|
+
mock_monotonic = MockMonotonic()
|
|
71
|
+
mocker.patch("time.monotonic", side_effect=mock_monotonic)
|
|
72
|
+
result = runner.invoke(app, ["5", "--break"])
|
|
73
|
+
assert result.exit_code == 0
|
|
74
|
+
assert "Break Time: 5 Minutes" in result.output
|
|
75
|
+
assert "Break complete. Ready to focus?" in result.output
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_focus_silent_flag(mocker: MockerFixture) -> None:
|
|
79
|
+
"""Test that the --silent flag omits the terminal bell."""
|
|
80
|
+
mocker.patch("time.sleep")
|
|
81
|
+
mock_bell = mocker.patch("rich.console.Console.bell")
|
|
82
|
+
|
|
83
|
+
mock_monotonic = MockMonotonic()
|
|
84
|
+
mocker.patch("time.monotonic", side_effect=mock_monotonic)
|
|
85
|
+
result = runner.invoke(app, ["1", "--silent"])
|
|
86
|
+
assert result.exit_code == 0
|
|
87
|
+
mock_bell.assert_not_called()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_focus_keyboard_interrupt(mocker: MockerFixture) -> None:
|
|
91
|
+
"""Test handling of KeyboardInterrupt (Ctrl+C)."""
|
|
92
|
+
# Simulate a KeyboardInterrupt on the first sleep call
|
|
93
|
+
mocker.patch("time.sleep", side_effect=KeyboardInterrupt)
|
|
94
|
+
mocker.patch("time.monotonic", return_value=0.0)
|
|
95
|
+
result = runner.invoke(app, ["1"])
|
|
96
|
+
# The application handles the interrupt gracefully, and exits with 130
|
|
97
|
+
assert result.exit_code == 130
|
|
98
|
+
assert "Session paused. Your focus still matters." in result.output
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_break_keyboard_interrupt(mocker: MockerFixture) -> None:
|
|
102
|
+
"""Test handling of KeyboardInterrupt during a break session."""
|
|
103
|
+
mocker.patch("time.sleep", side_effect=KeyboardInterrupt)
|
|
104
|
+
mocker.patch("time.monotonic", return_value=0.0)
|
|
105
|
+
result = runner.invoke(app, ["1", "--break"])
|
|
106
|
+
assert result.exit_code == 130
|
|
107
|
+
assert "Break paused. Rest is important too." in result.output
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_focus_early_keyboard_interrupt(mocker: MockerFixture) -> None:
|
|
111
|
+
"""Test handling of KeyboardInterrupt before rich is initialized."""
|
|
112
|
+
# Simulate a KeyboardInterrupt right when Console is initialized
|
|
113
|
+
mocker.patch(
|
|
114
|
+
"rich.console.Console.__init__",
|
|
115
|
+
side_effect=KeyboardInterrupt
|
|
116
|
+
)
|
|
117
|
+
result = runner.invoke(app, ["1"])
|
|
118
|
+
# The application handles the interrupt gracefully and uses raw print
|
|
119
|
+
assert result.exit_code == 130
|
|
120
|
+
assert "Session paused. Your focus still matters." in result.output
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_break_early_keyboard_interrupt(mocker: MockerFixture) -> None:
|
|
124
|
+
"""Test handling of early KeyboardInterrupt during a break session."""
|
|
125
|
+
mocker.patch(
|
|
126
|
+
"rich.console.Console.__init__",
|
|
127
|
+
side_effect=KeyboardInterrupt
|
|
128
|
+
)
|
|
129
|
+
result = runner.invoke(app, ["1", "--break"])
|
|
130
|
+
assert result.exit_code == 130
|
|
131
|
+
assert "Break paused. Rest is important too." in result.output
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_focus_double_keyboard_interrupt(mocker: MockerFixture) -> None:
|
|
135
|
+
"""Test handling KeyboardInterrupt within the interrupt handler."""
|
|
136
|
+
mocker.patch("time.sleep", side_effect=KeyboardInterrupt)
|
|
137
|
+
mocker.patch("time.monotonic", return_value=0.0)
|
|
138
|
+
|
|
139
|
+
class SideEffectFunc: # pylint: disable=R0903
|
|
140
|
+
"""Mock for side effect."""
|
|
141
|
+
|
|
142
|
+
def __init__(self) -> None:
|
|
143
|
+
self.count = 0
|
|
144
|
+
|
|
145
|
+
def __call__(
|
|
146
|
+
self, *args: Any, **kwargs: Any
|
|
147
|
+
) -> Any: # pylint: disable=unused-argument
|
|
148
|
+
if self.count == 2:
|
|
149
|
+
raise KeyboardInterrupt
|
|
150
|
+
self.count += 1
|
|
151
|
+
|
|
152
|
+
side_effect_func = SideEffectFunc()
|
|
153
|
+
mocker.patch("rich.console.Console.print", side_effect=side_effect_func)
|
|
154
|
+
|
|
155
|
+
result = runner.invoke(app, ["1"])
|
|
156
|
+
# The application gracefully handles the double interrupt and exits 130
|
|
157
|
+
assert result.exit_code == 130
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_version_flag() -> None:
|
|
161
|
+
"""Test that the --version flag outputs the version and exits."""
|
|
162
|
+
result = runner.invoke(app, ["--version"])
|
|
163
|
+
assert result.exit_code == 0
|
|
164
|
+
assert "zen-timer version" in result.output
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_version_not_found(mocker: MockerFixture) -> None:
|
|
168
|
+
"""Test fallback when the package version cannot be found."""
|
|
169
|
+
import importlib.metadata # pylint: disable=import-outside-toplevel
|
|
170
|
+
mocker.patch(
|
|
171
|
+
"importlib.metadata.version",
|
|
172
|
+
side_effect=importlib.metadata.PackageNotFoundError
|
|
173
|
+
)
|
|
174
|
+
result = runner.invoke(app, ["--version"])
|
|
175
|
+
assert result.exit_code == 0
|
|
176
|
+
assert "zen-timer version unknown" in result.output
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_focus_break_and_silent_flags(mocker: MockerFixture) -> None:
|
|
180
|
+
"""Test that combining --break and --silent flags works correctly."""
|
|
181
|
+
mocker.patch("time.sleep")
|
|
182
|
+
mock_bell = mocker.patch("rich.console.Console.bell")
|
|
183
|
+
|
|
184
|
+
mock_monotonic = MockMonotonic()
|
|
185
|
+
mocker.patch("time.monotonic", side_effect=mock_monotonic)
|
|
186
|
+
result = runner.invoke(app, ["3", "--break", "--silent"])
|
|
187
|
+
assert result.exit_code == 0
|
|
188
|
+
assert "Break Time: 3 Minutes" in result.output
|
|
189
|
+
assert "Break complete. Ready to focus?" in result.output
|
|
190
|
+
mock_bell.assert_not_called()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_focus_sleep_bounds_check(mocker: MockerFixture) -> None:
|
|
194
|
+
"""Test that negative sleep intervals are bounded to 0."""
|
|
195
|
+
mock_sleep = mocker.patch("time.sleep")
|
|
196
|
+
|
|
197
|
+
original_min = min
|
|
198
|
+
|
|
199
|
+
def mock_min(*args: Any, **kwargs: Any) -> Any:
|
|
200
|
+
# We only want to return -0.5 for timer.py calls
|
|
201
|
+
# timer.py calls: min(sleep_interval, remaining)
|
|
202
|
+
# sleep_interval is float, remaining is float.
|
|
203
|
+
if (len(args) == 2 and isinstance(args[0], float) and
|
|
204
|
+
isinstance(args[1], float)):
|
|
205
|
+
# If we are in the focus loop
|
|
206
|
+
if 0.0 <= args[1] <= 60.0:
|
|
207
|
+
return -0.5
|
|
208
|
+
return original_min(*args, **kwargs)
|
|
209
|
+
|
|
210
|
+
mocker.patch("builtins.min", side_effect=mock_min)
|
|
211
|
+
|
|
212
|
+
# We need sleep to be called at least once before breaking
|
|
213
|
+
class MockMonotonicBounds: # pylint: disable=R0903
|
|
214
|
+
"""Mock for time.monotonic bounds check."""
|
|
215
|
+
|
|
216
|
+
def __init__(self) -> None:
|
|
217
|
+
self.call_count = 0
|
|
218
|
+
|
|
219
|
+
def __call__(self) -> float:
|
|
220
|
+
if self.call_count == 0:
|
|
221
|
+
self.call_count += 1
|
|
222
|
+
return 0.0 # Start time
|
|
223
|
+
if self.call_count == 1:
|
|
224
|
+
self.call_count += 1
|
|
225
|
+
return 0.5 # First loop check
|
|
226
|
+
if self.call_count == 2:
|
|
227
|
+
self.call_count += 1
|
|
228
|
+
return 0.6 # Recalculate if UI triggers, or next loop
|
|
229
|
+
if self.call_count == 3:
|
|
230
|
+
self.call_count += 1
|
|
231
|
+
return 60.0 # Breaks loop
|
|
232
|
+
self.call_count += 1
|
|
233
|
+
return 65.0
|
|
234
|
+
|
|
235
|
+
mock_monotonic = MockMonotonicBounds()
|
|
236
|
+
mocker.patch("time.monotonic", side_effect=mock_monotonic)
|
|
237
|
+
|
|
238
|
+
result = runner.invoke(app, ["1"])
|
|
239
|
+
|
|
240
|
+
assert result.exit_code == 0
|
|
241
|
+
mock_sleep.assert_any_call(0)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_focus_double_keyboard_interrupt_cleanup(
|
|
245
|
+
mocker: MockerFixture
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Test handling of a double KeyboardInterrupt during cleanup."""
|
|
248
|
+
mocker.patch("time.sleep", side_effect=KeyboardInterrupt)
|
|
249
|
+
mocker.patch("time.monotonic", return_value=0.0)
|
|
250
|
+
|
|
251
|
+
class MockPrint: # pylint: disable=R0903
|
|
252
|
+
"""Mock for console print."""
|
|
253
|
+
|
|
254
|
+
def __init__(self) -> None:
|
|
255
|
+
self.call_count = 0
|
|
256
|
+
|
|
257
|
+
def __call__(
|
|
258
|
+
self, *args: Any, **kwargs: Any
|
|
259
|
+
) -> Any: # pylint: disable=unused-argument
|
|
260
|
+
self.call_count += 1
|
|
261
|
+
# Raise KeyboardInterrupt on the 6th print call to simulate a
|
|
262
|
+
# second Ctrl+C during the cleanup routine
|
|
263
|
+
if self.call_count == 6:
|
|
264
|
+
raise KeyboardInterrupt
|
|
265
|
+
|
|
266
|
+
mock_print = MockPrint()
|
|
267
|
+
mocker.patch("rich.console.Console.print", side_effect=mock_print)
|
|
268
|
+
|
|
269
|
+
result = runner.invoke(app, ["1"], catch_exceptions=False)
|
|
270
|
+
assert result.exit_code == 130
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_main(mocker: MockerFixture) -> None:
|
|
274
|
+
"""Test that main() calls the Typer app."""
|
|
275
|
+
mock_app = mocker.patch("zen.timer.app")
|
|
276
|
+
# pylint: disable=import-outside-toplevel
|
|
277
|
+
from zen.timer import main
|
|
278
|
+
main()
|
|
279
|
+
mock_app.assert_called_once()
|