psw 0.1.4__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.
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published] # GitHubで「Release」を作成・公開したときに自動起動
6
+
7
+ jobs:
8
+ build-n-publish:
9
+ name: Build and publish Python distro to PyPI
10
+ runs-on: ubuntu-latest
11
+
12
+ # ⚠️ Trusted Publisher を使うために必須の権限設定
13
+ permissions:
14
+ id-token: write # PyPIとのOIDC認証に必要
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: '3.12'
24
+
25
+ - name: Install dependencies (Hatch)
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install hatch
29
+
30
+ - name: Build package
31
+ run: |
32
+ hatch build
33
+
34
+ - name: Publish package distribution to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,96 @@
1
+ # Contributing to PSW
2
+
3
+ Thank you for your interest in contributing to PSW! This document provides guidelines for setting up your development environment, running tests, and submitting your contributions.
4
+
5
+ ---
6
+
7
+ ## 🛠️ Development Environment Setup
8
+
9
+ To run and test the program during development without installing it globally, you need to add the `src` directory to your `PYTHONPATH` environment variable.
10
+
11
+ Choose the command corresponding to your operating system and terminal:
12
+
13
+ ### macOS / Linux (Bash, Zsh, etc.)
14
+
15
+ ```bash
16
+ export PYTHONPATH=src
17
+ python -m psw timer 5s
18
+ ```
19
+
20
+ ### Windows (PowerShell)
21
+
22
+ ```powershell
23
+ $env:PYTHONPATH="src"
24
+ python -m psw timer 5s
25
+ ```
26
+
27
+ ### Windows (Command Prompt - `cmd.exe`)
28
+
29
+ ```cmd
30
+ set PYTHONPATH=src
31
+ python -m psw timer 5s
32
+ ```
33
+
34
+ ## 🧪 Testing
35
+
36
+ We use `pytest` for running our automated test suite. Before running tests, ensure you have the development dependencies installed.
37
+
38
+ ### 1. Install Development Dependencies
39
+
40
+ Navigate to the root directory of the project and install the package in editable mode with test dependencies:
41
+
42
+ ```bash
43
+ pip install -e ".[test]"
44
+ ```
45
+
46
+ ### 2. Run Tests
47
+
48
+ You can run the tests using either of the following commands:
49
+
50
+ ```bash
51
+ # Recommended
52
+ python -m pytest tests
53
+
54
+ # Alternative
55
+ pytest
56
+ ```
57
+
58
+ ## 🤝 How to Contribute
59
+
60
+ ### 1. Reporting Bugs & Suggesting Features
61
+
62
+ * Check the [Issues] tab to see if your bug or feature request has already been reported.
63
+ * If not, open a new issue. Please provide a clear description, steps to reproduce (for bugs), and your OS/Python version.
64
+
65
+ ### 2. Submitting Pull Requests
66
+
67
+ 1. **Fork** the repository and create your branch from `main`.
68
+ ```bash
69
+ git checkout -b feature/your-feature-name
70
+ ```
71
+ 2. **Implement your changes** and make sure you adhere to the project's coding style.
72
+ 3. **Write tests** for any new functionality or bug fixes.
73
+ 4. **Run the test suite** to ensure everything passes:
74
+ ```bash
75
+ python -m pytest tests
76
+ ```
77
+ 5. **Commit your changes** with a clear and descriptive commit message.
78
+ 6. **Push** to your fork and **submit a Pull Request (PR)** to the `main` branch of this repository.
79
+
80
+ ## 🎨 Coding Guidelines
81
+
82
+ To keep the codebase clean and consistent, please follow these guidelines:
83
+
84
+ * **PEP 8**: Follow standard Python coding style guidelines.
85
+ * **Type Hints**: Use type hinting for function arguments and return values where appropriate.
86
+ * **Docstrings**: Add docstrings to new classes and functions to explain their purpose and behavior.
87
+
88
+ ## To Build Package
89
+
90
+ ```
91
+ # 1. Install packaging tool
92
+ pip install pyinstaller
93
+
94
+ # 2. Build
95
+ python -m PyInstaller --onefile --paths src --name psw src/psw/__main__.py
96
+ ```
psw-0.1.4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kenji Otsuka
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.
psw-0.1.4/PKG-INFO ADDED
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: psw
3
+ Version: 0.1.4
4
+ Summary: A lightweight CLI stopwatch and timer in Python
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.8
7
+ Provides-Extra: test
8
+ Requires-Dist: pytest>=7.0.0; extra == 'test'
9
+ Description-Content-Type: text/markdown
10
+
11
+ # PSW - Python Stop Watch
12
+
13
+ `psw` is a lightweight, intuitive Command Line Interface (CLI) stopwatch and timer utility written in Python. It features live console rendering, interactive real-time controls, and built-in repeat functionalities without requiring any complex external audio dependencies.
14
+
15
+ ## Install
16
+
17
+ You can install `psw` via pip:
18
+
19
+ ```bash
20
+ pip install psw
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### 1. Stopwatch Mode
26
+
27
+ Measure elapsed time directly in your terminal.
28
+
29
+ #### Start the Stopwatch
30
+
31
+ To start the stopwatch, run:
32
+
33
+ ```bash
34
+ psw start
35
+ ```
36
+
37
+ The live clock will be displayed in the console:
38
+
39
+ ```text
40
+ 00 h 00 m 00.000 s
41
+ ```
42
+
43
+ #### Options
44
+
45
+ * **`-p, --precision`**: Specifies the number of decimal places for seconds.
46
+ * **Default**: `3` (millisecond precision)
47
+ * **Example**: `psw start -p 2` will display `00 h 00 m 00.00 s`
48
+
49
+ #### Interactive Controls (While Running)
50
+
51
+ While the stopwatch is active, you can control it in real-time using the following keys:
52
+
53
+ * **`s`**: Pause / Resume the stopwatch.
54
+ * **`l`**: Record a lap time. The current lap time will be printed below the running clock without stopping the main timer.
55
+ * **`q` (or `Ctrl+C`)**: Quit the stopwatch and display the final summary.
56
+
57
+ ### 2. Timer Mode
58
+
59
+ Count down from a specified duration with advanced repeat and notification options.
60
+
61
+ #### Start the Timer
62
+
63
+ To start a countdown, use the `timer` command followed by the duration. You can specify hours (`h`), minutes (`m`), and seconds (`s`).
64
+
65
+ ```bash
66
+ # Set a timer for 5 minutes and 30 seconds
67
+ psw timer 5m 30s
68
+
69
+ # Set a timer for 1 hour
70
+ psw timer 1h
71
+ ```
72
+
73
+ The countdown will be displayed in the console:
74
+
75
+ ```text
76
+ 00 h 05 m 30.000 s
77
+
78
+ ```
79
+
80
+ #### Options
81
+
82
+ * **`-p, --precision`**: Same as the stopwatch mode, specifies the decimal places for seconds (Default: `3`).
83
+ * **`-r, --repeat [<integer>]`**: Restarts the timer when it reaches zero.
84
+ * Use without arguments for an **infinite loop**.
85
+ * *Example*: `psw timer 1m -r`
86
+ * Specify an integer to repeat a **specific number of times**.
87
+ * *Example*: `psw timer 1m -r 3` (Runs a 1-minute timer 3 times)
88
+ * **`-m, --mute`**: Disables the system alert sound (terminal bell) when the timer ends.
89
+
90
+ #### Interactive Controls (While Running)
91
+
92
+ * **`s`**: Pause / Resume the countdown.
93
+ * **`q` (or `Ctrl+C`)**: Cancel and exit the timer.
94
+
95
+ > [!NOTE]
96
+ > When the timer reaches `00 h 00 m 00.000 s`, the application plays a platform-appropriate alert notification (using `winsound.MessageBeep()` on Windows, and emitting a standard terminal bell sound `\a` on other platforms or as a fallback). No special OS-level audio permissions or external audio library imports are required, making it lightweight and cross-platform compatible.
psw-0.1.4/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # PSW - Python Stop Watch
2
+
3
+ `psw` is a lightweight, intuitive Command Line Interface (CLI) stopwatch and timer utility written in Python. It features live console rendering, interactive real-time controls, and built-in repeat functionalities without requiring any complex external audio dependencies.
4
+
5
+ ## Install
6
+
7
+ You can install `psw` via pip:
8
+
9
+ ```bash
10
+ pip install psw
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### 1. Stopwatch Mode
16
+
17
+ Measure elapsed time directly in your terminal.
18
+
19
+ #### Start the Stopwatch
20
+
21
+ To start the stopwatch, run:
22
+
23
+ ```bash
24
+ psw start
25
+ ```
26
+
27
+ The live clock will be displayed in the console:
28
+
29
+ ```text
30
+ 00 h 00 m 00.000 s
31
+ ```
32
+
33
+ #### Options
34
+
35
+ * **`-p, --precision`**: Specifies the number of decimal places for seconds.
36
+ * **Default**: `3` (millisecond precision)
37
+ * **Example**: `psw start -p 2` will display `00 h 00 m 00.00 s`
38
+
39
+ #### Interactive Controls (While Running)
40
+
41
+ While the stopwatch is active, you can control it in real-time using the following keys:
42
+
43
+ * **`s`**: Pause / Resume the stopwatch.
44
+ * **`l`**: Record a lap time. The current lap time will be printed below the running clock without stopping the main timer.
45
+ * **`q` (or `Ctrl+C`)**: Quit the stopwatch and display the final summary.
46
+
47
+ ### 2. Timer Mode
48
+
49
+ Count down from a specified duration with advanced repeat and notification options.
50
+
51
+ #### Start the Timer
52
+
53
+ To start a countdown, use the `timer` command followed by the duration. You can specify hours (`h`), minutes (`m`), and seconds (`s`).
54
+
55
+ ```bash
56
+ # Set a timer for 5 minutes and 30 seconds
57
+ psw timer 5m 30s
58
+
59
+ # Set a timer for 1 hour
60
+ psw timer 1h
61
+ ```
62
+
63
+ The countdown will be displayed in the console:
64
+
65
+ ```text
66
+ 00 h 05 m 30.000 s
67
+
68
+ ```
69
+
70
+ #### Options
71
+
72
+ * **`-p, --precision`**: Same as the stopwatch mode, specifies the decimal places for seconds (Default: `3`).
73
+ * **`-r, --repeat [<integer>]`**: Restarts the timer when it reaches zero.
74
+ * Use without arguments for an **infinite loop**.
75
+ * *Example*: `psw timer 1m -r`
76
+ * Specify an integer to repeat a **specific number of times**.
77
+ * *Example*: `psw timer 1m -r 3` (Runs a 1-minute timer 3 times)
78
+ * **`-m, --mute`**: Disables the system alert sound (terminal bell) when the timer ends.
79
+
80
+ #### Interactive Controls (While Running)
81
+
82
+ * **`s`**: Pause / Resume the countdown.
83
+ * **`q` (or `Ctrl+C`)**: Cancel and exit the timer.
84
+
85
+ > [!NOTE]
86
+ > When the timer reaches `00 h 00 m 00.000 s`, the application plays a platform-appropriate alert notification (using `winsound.MessageBeep()` on Windows, and emitting a standard terminal bell sound `\a` on other platforms or as a fallback). No special OS-level audio permissions or external audio library imports are required, making it lightweight and cross-platform compatible.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "psw"
7
+ version = "0.1.4"
8
+ description = "A lightweight CLI stopwatch and timer in Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ # external libraries
12
+ dependencies = []
13
+
14
+ [project.scripts]
15
+ # psw in command calls main function in cli.py
16
+ psw = "psw.cli:main"
17
+
18
+ [project.optional-dependencies]
19
+ test = [
20
+ "pytest>=7.0.0",
21
+ ]
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/psw"]
@@ -0,0 +1,10 @@
1
+ # パッケージ外から直接 Stopwatch や Timer をインポートできるように公開します。
2
+ # 例: from psw import Stopwatch, Timer
3
+ from psw.stopwatch import Stopwatch
4
+ from psw.timer import Timer
5
+
6
+ # パッケージのバージョン(pyproject.toml と合わせておくと管理しやすいです)
7
+ __version__ = "0.1.4"
8
+
9
+ # `from psw import *` をした際にインポートされるクラスを指定
10
+ __all__ = ["Stopwatch", "Timer"]
@@ -0,0 +1,7 @@
1
+ from psw.cli import main
2
+
3
+ # This file enables the command `python -m psw` to run the CLI of the `psw` package.
4
+
5
+ if __name__ == "__main__":
6
+ # simple structure to call the main() function of cli.py
7
+ main()
@@ -0,0 +1,96 @@
1
+ import argparse
2
+ import sys
3
+
4
+
5
+ def main():
6
+ # 親パーサーの設定(プログラム全体のヘルプや基本情報を定義)
7
+ parser = argparse.ArgumentParser(
8
+ description="PSW - Python Stop Watch & Timer CLI tool",
9
+ formatter_class=argparse.RawDescriptionHelpFormatter,
10
+ )
11
+
12
+ # サブコマンド(start / timer)を管理するパーサーを追加
13
+ subparsers = parser.add_subparsers(
14
+ dest="command",
15
+ required=True,
16
+ help="Subcommands"
17
+ )
18
+
19
+ # ---------------------------------------------------------
20
+ # 1. 'start' (Stopwatch) サブコマンドの設定
21
+ # ---------------------------------------------------------
22
+ parser_start = subparsers.add_parser(
23
+ "start",
24
+ help="Start the stopwatch"
25
+ )
26
+ parser_start.add_argument(
27
+ "-p", "--precision",
28
+ type=int,
29
+ default=3,
30
+ help="Decimal places for seconds (default: 3)"
31
+ )
32
+
33
+ # ---------------------------------------------------------
34
+ # 2. 'timer' サブコマンドの設定
35
+ # ---------------------------------------------------------
36
+ parser_timer = subparsers.add_parser(
37
+ "timer",
38
+ help="Start the countdown timer"
39
+ )
40
+ # 時間指定(例: 5m, 30s, 1h 30m など可変長の引数を受け取る)
41
+ parser_timer.add_argument(
42
+ "duration",
43
+ nargs="+",
44
+ help="Duration for the timer (e.g., '5m 30s', '1h', '45s')"
45
+ )
46
+ parser_timer.add_argument(
47
+ "-p", "--precision",
48
+ type=int,
49
+ default=3,
50
+ help="Decimal places for seconds (default: 3)"
51
+ )
52
+ parser_timer.add_argument(
53
+ "-r", "--repeat",
54
+ nargs="?", # optional
55
+ const=-1, # -1 when only `-r` is used without scceeding number
56
+ default=1,
57
+ type=int, # Recognize value as Integer when specified
58
+ help="Repeat the timer. Use '-r' for infinite loop, or '-r N' to repeat N times."
59
+ )
60
+ parser_timer.add_argument(
61
+ "-m", "--mute",
62
+ action="store_true",
63
+ help="Mute the terminal bell (\a) alert sound when the timer ends"
64
+ )
65
+
66
+ args = parser.parse_args()
67
+
68
+ # 各コマンドに応じた処理の分岐
69
+ if args.command == "start":
70
+ try:
71
+ from psw.stopwatch import Stopwatch
72
+ # ストップウォッチの初期化と実行
73
+ sw = Stopwatch(precision=args.precision)
74
+ sw.run()
75
+ except ImportError:
76
+ print("[Error] 'stopwatch.py' is not implemented yet.", file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ elif args.command == "timer":
80
+ try:
81
+ from psw.timer import Timer
82
+ # タイマーの初期化と実行
83
+ timer = Timer(
84
+ duration_strings=args.duration,
85
+ precision=args.precision,
86
+ repeat=args.repeat,
87
+ mute=args.mute
88
+ )
89
+ timer.run()
90
+ except ImportError:
91
+ print("[Error] 'timer.py' is not implemented yet.", file=sys.stderr)
92
+ sys.exit(1)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1,91 @@
1
+ import time
2
+ import sys
3
+ from psw.utils import KeyListener
4
+
5
+
6
+ class Stopwatch:
7
+ def __init__(self, precision: int = 3):
8
+ if precision < 0:
9
+ raise ValueError("precision must be >= 0")
10
+ self.precision = precision
11
+ self.listener = KeyListener()
12
+
13
+ self.start_time = 0.0
14
+ self.elapsed_time = 0.0
15
+ self.running = False
16
+ self.laps = []
17
+
18
+ def format_time(self, seconds: float) -> str:
19
+ """秒数を '00 h 00 m 00.000 s' のフォーマットに整形する"""
20
+ h = int(seconds // 3600)
21
+ m = int((seconds % 3600) // 60)
22
+ s = seconds % 60
23
+
24
+ # 指定された精度(小数点以下)で秒数をフォーマット
25
+ seconds_str = f"{s:02.{self.precision}f}"
26
+ # 万が一秒が繰り上がって60.00...になった場合の簡易補正
27
+ if float(seconds_str) >= 60.0:
28
+ seconds_str = f"{0.0:02.{self.precision}f}"
29
+ m += 1
30
+ if m >= 60:
31
+ m = 0
32
+ h += 1
33
+
34
+ return f"{h:02d} h {m:02d} m {seconds_str} s"
35
+
36
+ def run(self):
37
+ self.start_time = time.monotonic()
38
+ self.running = True
39
+
40
+ print("Stopwatch started. Press 's' to pause/resume, 'l' to record lap, 'q' to quit.\n")
41
+ # 1行分のスペースをあける(リアルタイム描画用)
42
+ print()
43
+
44
+ try:
45
+ while True:
46
+ if self.running:
47
+ self.elapsed_time = time.monotonic() - self.start_time
48
+
49
+ # カーソルを上に戻して、現在の時間を描画
50
+ # \033[F moves cursor to the previous line,
51
+ # \033[K clear the text to the end of the line.
52
+ sys.stdout.write(f"\033[F\033[K{self.format_time(self.elapsed_time)}\n")
53
+ sys.stdout.flush()
54
+
55
+ # check key input
56
+ char = self.listener.get_char()
57
+ if char == 'q':
58
+ break
59
+ elif char == 's':
60
+ if self.running:
61
+ # stop
62
+ self.elapsed_time = time.monotonic() - self.start_time
63
+ self.running = False
64
+ else:
65
+ # resume
66
+ self.start_time = time.monotonic() - self.elapsed_time
67
+ self.running = True
68
+ elif char == 'l':
69
+ if self.running:
70
+ lap_num = len(self.laps) + 1
71
+ lap_str = f" Lap #{lap_num:02d}: {self.format_time(self.elapsed_time)}"
72
+ self.laps.append(lap_str)
73
+ # 一旦現在の時間表示の下にラップを挿入するために、描画位置を調整
74
+ print(lap_str)
75
+ print() # 次の時間描画のための空行
76
+
77
+ # CPU負荷を下げるためのウェイト(ミリ秒精度に合わせて調整)
78
+ # 精度が高い場合は更新頻度を上げ、低い場合は下げる
79
+ sleep_time = 0.01 if self.precision > 2 else 0.05
80
+ time.sleep(sleep_time)
81
+
82
+ finally:
83
+ self.listener.close()
84
+
85
+ # 終了時のまとめ表示
86
+ print(f"\n--- Finished ---")
87
+ print(f"Total Time: {self.format_time(self.elapsed_time)}")
88
+ if self.laps:
89
+ print("Laps:")
90
+ for lap in self.laps:
91
+ print(lap)
@@ -0,0 +1,197 @@
1
+ import time
2
+ import sys
3
+ import re
4
+ import os
5
+ from psw.utils import KeyListener
6
+
7
+ is_windows = os.name == "nt" or sys.platform.startswith("win")
8
+ if is_windows:
9
+ import winsound
10
+
11
+ class Timer:
12
+ def __init__(
13
+ self,
14
+ duration_strings: list,
15
+ precision: int = 3,
16
+ repeat: int = 1,
17
+ mute: bool = False,
18
+ ):
19
+ if precision < 0:
20
+ raise ValueError("precision must be >= 0")
21
+ self.duration_strings = duration_strings
22
+ self.precision = precision
23
+ self.repeat = repeat
24
+ self.mute = mute
25
+
26
+ self.listener = KeyListener()
27
+
28
+ # 入力された文字列(['5m', '30s'] など)から合計秒数を算出
29
+ self.total_seconds = self.parse_duration(duration_strings)
30
+ self.remaining_time = self.total_seconds
31
+
32
+ self.running = False
33
+ self.start_time = 0.0
34
+
35
+ def parse_duration(self, duration_strings: list) -> float:
36
+ """
37
+ ['1h', '30m', '45s'] のような入力リストを解析し、合計秒数を返す。
38
+ 単位が指定されていない単なる数値は「秒」として扱う。
39
+ """
40
+ total = 0.0
41
+ # 時間パース用の正規表現 (例: "5.5m", "10s", "1h")
42
+ pattern = re.compile(r"^([\d.]+)([hms]?)$")
43
+
44
+ for s in duration_strings:
45
+ s = s.strip().lower()
46
+ match = pattern.match(s)
47
+ if not match:
48
+ print(f"[Warning] Could not parse duration part: '{s}'. Ignored.", file=sys.stderr)
49
+ continue
50
+
51
+ value_str, unit = match.groups()
52
+ try:
53
+ value = float(value_str)
54
+ except ValueError:
55
+ continue
56
+
57
+ if unit == "h":
58
+ total += value * 3600
59
+ elif unit == "m":
60
+ total += value * 60
61
+ elif unit == "s" or unit == "":
62
+ total += value
63
+
64
+ if total <= 0:
65
+ print("[Error] Total duration must be greater than 0.", file=sys.stderr)
66
+ sys.exit(1)
67
+
68
+ return total
69
+
70
+ def format_time(self, seconds: float) -> str:
71
+ """秒数を '00 h 00 m 00.000 s' のフォーマットに整形する"""
72
+ if seconds < 0:
73
+ seconds = 0.0
74
+
75
+ h = int(seconds // 3600)
76
+ m = int((seconds % 3600) // 60)
77
+ s = seconds % 60
78
+
79
+ seconds_str = f"{s:02.{self.precision}f}"
80
+ if float(seconds_str) >= 60.0:
81
+ seconds_str = f"{0.0:02.{self.precision}f}"
82
+ m += 1
83
+ if m >= 60:
84
+ m = 0
85
+ h += 1
86
+
87
+ return f"{h:02d} h {m:02d} m {seconds_str} s"
88
+
89
+ def run_single_timer(self, cycle_num: int = None) -> bool:
90
+ """
91
+ 1回分のタイマーカウントダウンを実行する。
92
+ 戻り値: True (完走した), False (ユーザーが 'q' で中断した)
93
+ """
94
+ self.remaining_time = self.total_seconds
95
+ self.start_time = time.monotonic()
96
+ self.running = True
97
+
98
+ # 進捗タイトルの表示
99
+ if cycle_num is not None:
100
+ print(f"--- Timer Cycle #{cycle_num} ---")
101
+ else:
102
+ print("--- Timer Started ---")
103
+ print("Press 's' to pause/resume, 'q' to quit.\n\n")
104
+
105
+ try:
106
+ while self.remaining_time > 0:
107
+ if self.running:
108
+ # 経過時間を引き、残りの時間を計算
109
+ elapsed = time.monotonic() - self.start_time
110
+ self.remaining_time = self.total_seconds - elapsed
111
+ if self.remaining_time < 0:
112
+ self.remaining_time = 0.0
113
+
114
+ # カーソルを上に戻して、現在の時間を描画
115
+ sys.stdout.write(f"\033[F\033[K{self.format_time(self.remaining_time)}\n")
116
+ sys.stdout.flush()
117
+
118
+ if self.remaining_time <= 0:
119
+ break
120
+
121
+ # キー入力チェック
122
+ char = self.listener.get_char()
123
+ if char == 'q':
124
+ return False
125
+ elif char == 's':
126
+ if self.running:
127
+ # 一時停止
128
+ self.running = False
129
+ else:
130
+ # 再開
131
+ elapsed = self.total_seconds - self.remaining_time
132
+ self.start_time = time.monotonic() - elapsed
133
+ self.running = True
134
+
135
+ sleep_time = 0.01 if self.precision > 2 else 0.05
136
+ time.sleep(sleep_time)
137
+
138
+ # タイムアップ時、00:00:00.000を表示させる
139
+ sys.stdout.write(f"\033[F\033[K{self.format_time(0.0)}\n")
140
+ sys.stdout.flush()
141
+
142
+ # ビープ音の鳴動
143
+ if not self.mute:
144
+ if is_windows:
145
+ try:
146
+ # winsound.MessageBeep() は Windows標準のアラート音を鳴らします
147
+ # (winsound.Beep(1000, 500) で特定の周波数を鳴らすことも可能です)
148
+ winsound.MessageBeep()
149
+ except Exception:
150
+ sys.stdout.write("\a")
151
+ sys.stdout.flush()
152
+ else:
153
+ # mac or linux
154
+ sys.stdout.write("\a")
155
+ sys.stdout.flush()
156
+
157
+ print("\nTime's up!")
158
+ return True
159
+
160
+ finally:
161
+ # 1回分の表示領域をクリアして改行を整える
162
+ print()
163
+
164
+ def run(self):
165
+ print("run")
166
+ try:
167
+ # 1. 回数指定リピートがある場合
168
+ if self.repeat > 0:
169
+ for i in range(1, self.repeat + 1):
170
+ completed = self.run_single_timer(cycle_num=i)
171
+ if not completed:
172
+ print("Timer canceled by user.")
173
+ break
174
+ # ループ間に少しウェイトを挟む(即座に次が始まって画面が崩れるのを防ぐ)
175
+ if i < self.repeat:
176
+ time.sleep(1)
177
+
178
+ # 2. 無限リピート(-r)の場合
179
+ elif self.repeat == -1:
180
+ cycle = 1
181
+ while True:
182
+ completed = self.run_single_timer(cycle_num=cycle)
183
+ if not completed:
184
+ print("Timer canceled by user.")
185
+ break
186
+ cycle += 1
187
+ time.sleep(1)
188
+
189
+ # 3. 通常の1回切りタイマーの場合
190
+ else:
191
+ print("Invalid repeat count. It must be greater than 0, or -1 for infinite.")
192
+ # completed = self.run_single_timer()
193
+ # if not completed:
194
+ # print("Timer canceled by user.")
195
+
196
+ finally:
197
+ self.listener.close()
@@ -0,0 +1,50 @@
1
+ import sys
2
+
3
+ # OS判定
4
+ is_windows = sys.platform.startswith("win")
5
+
6
+ if is_windows:
7
+ import msvcrt
8
+ else:
9
+ import select
10
+ import tty
11
+ import termios
12
+
13
+
14
+ class KeyListener:
15
+ """ノンブロッキングでキー入力を監視するクラス"""
16
+
17
+ def __init__(self):
18
+ self.old_settings = None
19
+ if not is_windows and sys.stdin.isatty():
20
+ # ターミナルの標準入力設定を保存
21
+ self.old_settings = termios.tcgetattr(sys.stdin)
22
+
23
+ def get_char(self) -> str:
24
+ """押されたキーを1文字取得する(押されていなければ空文字を返す)"""
25
+ if is_windows:
26
+ if msvcrt.kbhit():
27
+ # Windowsのキー取得
28
+ try:
29
+ char = msvcrt.getch().decode("utf-8").lower()
30
+ return char
31
+ except UnicodeDecodeError:
32
+ return ""
33
+ return ""
34
+ else:
35
+ # macOS/Linuxのキー取得(cbreakモードに一時的に切り替える)
36
+ try:
37
+ tty.setcbreak(sys.stdin.fileno())
38
+ # 読み込み可能か確認(タイムアウト0秒)
39
+ dr, _, _ = select.select([sys.stdin], [], [], 0)
40
+ if dr:
41
+ return sys.stdin.read(1).lower()
42
+ return ""
43
+ finally:
44
+ # ターミナル設定を元に戻す
45
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_settings)
46
+
47
+ def close(self):
48
+ """終了処理(ターミナルの設定を完全に復元)"""
49
+ if not is_windows and self.old_settings:
50
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_settings)
File without changes
@@ -0,0 +1,58 @@
1
+ import sys
2
+ from unittest.mock import patch, MagicMock
3
+ import pytest
4
+ from psw.cli import main
5
+
6
+
7
+ # 1. 存在しないサブコマンドや不正なオプションが指定された場合のテスト
8
+ def test_cli_invalid_command():
9
+ # 引数に存在しないコマンド 'invalid_cmd' を渡す模擬設定
10
+ with patch.object(sys, "argv", ["psw", "invalid_cmd"]):
11
+ # argparseはパースに失敗すると SystemExit(code=2) を発生させます
12
+ with pytest.raises(SystemExit) as excinfo:
13
+ main()
14
+ assert excinfo.value.code == 2
15
+
16
+
17
+ # 2. 'start' (Stopwatch) コマンドのパーステスト
18
+ @patch("psw.stopwatch.Stopwatch")
19
+ def test_cli_start_command(mock_stopwatch):
20
+ # 'psw start -p 2' が入力されたと仮定
21
+ with patch.object(sys, "argv", ["psw", "start", "-p", "2"]):
22
+ main()
23
+
24
+ # Stopwatchクラスが正しい引数でインスタンス化され、run()が実行されたか検証
25
+ mock_stopwatch.assert_called_once_with(precision=2)
26
+ mock_stopwatch.return_value.run.assert_called_once()
27
+
28
+
29
+ # 3. 'timer' コマンドのパーステスト
30
+ @patch("psw.timer.Timer")
31
+ def test_cli_timer_command(mock_timer):
32
+ # 'psw timer 1m 30s -p 1 -r -m' が入力されたと仮定
33
+ with patch.object(sys, "argv", ["psw", "timer", "1m", "30s", "-p", "1", "-r", "-m"]):
34
+ main()
35
+
36
+ # Timerクラスが各オプションを正しく受け取ってインスタンス化されたか検証
37
+ mock_timer.assert_called_once_with(
38
+ duration_strings=["1m", "30s"],
39
+ precision=1,
40
+ repeat=-1,
41
+ mute=True
42
+ )
43
+ mock_timer.return_value.run.assert_called_once()
44
+
45
+
46
+ # 4. 'timer' コマンドで count オプションが指定された場合のパーステスト
47
+ @patch("psw.timer.Timer")
48
+ def test_cli_timer_count_option(mock_timer):
49
+ # 'psw timer 10s -r 3' が入力されたと仮定
50
+ with patch.object(sys, "argv", ["psw", "timer", "10s", "-r", "3"]):
51
+ main()
52
+
53
+ mock_timer.assert_called_once_with(
54
+ duration_strings=["10s"],
55
+ precision=3, # デフォルト値
56
+ repeat=3, # 3 times repeat
57
+ mute=False # デフォルト値
58
+ )
@@ -0,0 +1,32 @@
1
+ import pytest
2
+ from psw.timer import Timer
3
+
4
+
5
+ # 1. 正常な時間パースのテスト
6
+ @pytest.mark.parametrize(
7
+ "duration_list, expected_seconds",
8
+ [
9
+ (["10s"], 10.0),
10
+ (["5m"], 300.0),
11
+ (["1h"], 3600.0),
12
+ (["1h", "30m", "15s"], 5415.0), # 複数引数の組み合わせ
13
+ (["0.5m"], 30.0), # 小数点
14
+ (["10"], 10.0), # 単位なし(デフォルト秒)
15
+ ],
16
+ )
17
+ def test_parse_duration_success(duration_list, expected_seconds):
18
+ # Timerインスタンスを作成してパース結果を検証
19
+ # (テスト実行時にタイマー自体は動かさないため、ダミーの値を渡します)
20
+ timer = Timer(duration_strings=duration_list, mute=True)
21
+ assert timer.total_seconds == expected_seconds
22
+
23
+
24
+ # 2. 異常な入力に対するテスト
25
+ def test_parse_duration_invalid_and_exit():
26
+ # 0秒以下や、解釈不能な文字列だけで実行された場合、
27
+ # プログラムが SystemExit (sys.exit(1)) で終了することを確認します。
28
+ with pytest.raises(SystemExit) as excinfo:
29
+ Timer(duration_strings=["invalid_string"], mute=True)
30
+
31
+ # 終了コードが 1 であることを検証
32
+ assert excinfo.value.code == 1