askr 0.1.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.
- askr-0.1.0/.idea/.gitignore +5 -0
- askr-0.1.0/.idea/askr.iml +15 -0
- askr-0.1.0/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- askr-0.1.0/.idea/misc.xml +4 -0
- askr-0.1.0/.idea/modules.xml +8 -0
- askr-0.1.0/.idea/workspace.xml +68 -0
- askr-0.1.0/LICENSE +21 -0
- askr-0.1.0/PKG-INFO +104 -0
- askr-0.1.0/README.md +82 -0
- askr-0.1.0/main.py +0 -0
- askr-0.1.0/pyproject.toml +27 -0
- askr-0.1.0/src/askr/__init__.py +24 -0
- askr-0.1.0/src/askr/core.py +361 -0
- askr-0.1.0/src/askr/py.typed +0 -0
- askr-0.1.0/tests/test_core.py +221 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager">
|
|
4
|
+
<content url="file://$MODULE_DIR$">
|
|
5
|
+
<sourceFolder url="file://$MODULE_DIR$/src" />
|
|
6
|
+
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
7
|
+
</content>
|
|
8
|
+
<orderEntry type="jdk" jdkName="~/PycharmProjects/askr/.venv" jdkType="Python SDK" />
|
|
9
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
10
|
+
</component>
|
|
11
|
+
<component name="PackageRequirementsSettings" />
|
|
12
|
+
<component name="PyDocumentationSettings" />
|
|
13
|
+
<component name="ReSTService" />
|
|
14
|
+
<component name="TestRunnerService" />
|
|
15
|
+
</module>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="AutoImportSettings">
|
|
4
|
+
<option name="autoReloadType" value="SELECTIVE" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="ChangeListManager">
|
|
7
|
+
<list default="true" id="183b80c6-482c-4cb9-a416-903c9521f399" name="Changes" comment="" />
|
|
8
|
+
<option name="SHOW_DIALOG" value="false" />
|
|
9
|
+
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
10
|
+
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
11
|
+
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
12
|
+
</component>
|
|
13
|
+
<component name="FileTemplateManagerImpl">
|
|
14
|
+
<option name="RECENT_TEMPLATES">
|
|
15
|
+
<list>
|
|
16
|
+
<option value="Python Script" />
|
|
17
|
+
</list>
|
|
18
|
+
</option>
|
|
19
|
+
</component>
|
|
20
|
+
<component name="ProjectColorInfo"><![CDATA[{
|
|
21
|
+
"associatedIndex": 3,
|
|
22
|
+
"fromUser": false
|
|
23
|
+
}]]></component>
|
|
24
|
+
<component name="ProjectId" id="3JuvSseSHqQZ7r2Z9XoKBriCkN0" />
|
|
25
|
+
<component name="ProjectViewState">
|
|
26
|
+
<option name="hideEmptyMiddlePackages" value="true" />
|
|
27
|
+
<option name="showLibraryContents" value="true" />
|
|
28
|
+
</component>
|
|
29
|
+
<component name="PropertiesComponent"><![CDATA[{
|
|
30
|
+
"keyToString": {
|
|
31
|
+
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
32
|
+
"Python tests.Python tests in test_core.py.executor": "Run",
|
|
33
|
+
"Python.core.executor": "Run",
|
|
34
|
+
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
35
|
+
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
|
36
|
+
"codeWithMe.voiceChat.enabledByDefault": "false",
|
|
37
|
+
"last_opened_file_path": "/Users/lev/PycharmProjects/askr",
|
|
38
|
+
"nodejs_package_manager_path": "npm",
|
|
39
|
+
"python.packages.toolwindow.project.migrated.to.right.top.v2": "true"
|
|
40
|
+
}
|
|
41
|
+
}]]></component>
|
|
42
|
+
<component name="RecentsManager">
|
|
43
|
+
<key name="CopyFile.RECENT_KEYS">
|
|
44
|
+
<recent name="$PROJECT_DIR$" />
|
|
45
|
+
<recent name="$PROJECT_DIR$/src/askr" />
|
|
46
|
+
</key>
|
|
47
|
+
</component>
|
|
48
|
+
<component name="SharedIndexes">
|
|
49
|
+
<attachedChunks>
|
|
50
|
+
<set>
|
|
51
|
+
<option value="bundled-python-sdk-73a66231627c-5bd6d13a2a75-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-262.10968.92" />
|
|
52
|
+
</set>
|
|
53
|
+
</attachedChunks>
|
|
54
|
+
</component>
|
|
55
|
+
<component name="TaskManager">
|
|
56
|
+
<task active="true" id="Default" summary="Default task">
|
|
57
|
+
<changelist id="183b80c6-482c-4cb9-a416-903c9521f399" name="Changes" comment="" />
|
|
58
|
+
<created>1790523771170</created>
|
|
59
|
+
<option name="number" value="Default" />
|
|
60
|
+
<option name="presentableId" value="Default" />
|
|
61
|
+
<updated>1790523771170</updated>
|
|
62
|
+
</task>
|
|
63
|
+
<servers />
|
|
64
|
+
</component>
|
|
65
|
+
<component name="TypeScriptGeneratedFilesManager">
|
|
66
|
+
<option name="version" value="3" />
|
|
67
|
+
</component>
|
|
68
|
+
</project>
|
askr-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prevolut Ltd
|
|
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.
|
askr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: askr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple, validated input prompts for the terminal
|
|
5
|
+
Author: Prevolut Ltd
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cli,input,prompt,terminal,validation
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# askr
|
|
24
|
+
|
|
25
|
+
Simple, validated input prompts for the terminal.
|
|
26
|
+
|
|
27
|
+
Every function keeps asking until the answer is valid, so you never have to write
|
|
28
|
+
another `while True` / `try` / `except ValueError` loop around `input()` again.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install askr
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.10 or newer. No dependencies.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from datetime import date
|
|
42
|
+
from askr import ask_choice, ask_date, ask_float, ask_int, ask_password, ask_path, ask_str, ask_yn
|
|
43
|
+
|
|
44
|
+
age = ask_int("Your age: ", min_value=0, max_value=120)
|
|
45
|
+
price = ask_float("Price: ", min_value=0)
|
|
46
|
+
name = ask_str("Your name: ", min_length=1, max_length=40)
|
|
47
|
+
|
|
48
|
+
if ask_yn("Continue?"): # Enter = yes
|
|
49
|
+
...
|
|
50
|
+
if ask_yn("Delete everything?", default=False): # Enter = no
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
mode = ask_choice("Difficulty?", ["Easy", "Medium", "Hard"], default="Medium")
|
|
54
|
+
|
|
55
|
+
password = ask_password(confirm=True, min_length=8, require_upper=True,
|
|
56
|
+
require_digit=True, require_special=True)
|
|
57
|
+
pin = ask_password("PIN: ", pattern=r"\d{4}", pattern_hint="be exactly 4 digits")
|
|
58
|
+
|
|
59
|
+
file = ask_path("File to open: ", kind="file")
|
|
60
|
+
birthday = ask_date("Birthday: ", ("%Y-%m-%d", "%d.%m.%Y"), max_date=date.today())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
What the user sees:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Your age: abc
|
|
67
|
+
Invalid entry. Please enter a whole number.
|
|
68
|
+
Your age: 200
|
|
69
|
+
The number must be between 0 and 120.
|
|
70
|
+
|
|
71
|
+
Difficulty?
|
|
72
|
+
1. Easy
|
|
73
|
+
2. Medium (default)
|
|
74
|
+
3. Hard
|
|
75
|
+
Your choice: hard
|
|
76
|
+
|
|
77
|
+
Password:
|
|
78
|
+
The password must:
|
|
79
|
+
- contain an uppercase letter
|
|
80
|
+
- contain a special character
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Functions
|
|
84
|
+
|
|
85
|
+
| Function | Returns | Validates |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `ask_int` | `int` | whole number, optional `min_value` / `max_value` |
|
|
88
|
+
| `ask_float` | `float` | finite number, optional `min_value` / `max_value` |
|
|
89
|
+
| `ask_str` | `str` | optional `min_length` / `max_length`, strips whitespace by default |
|
|
90
|
+
| `ask_yn` | `bool` | y / yes / n / no, Enter returns `default` |
|
|
91
|
+
| `ask_choice` | `str` | option number or name (case-insensitive), optional `default` |
|
|
92
|
+
| `ask_password` | `str` | hidden input, optional confirmation, length, character classes, regex |
|
|
93
|
+
| `ask_path` | `Path` | must exist (optional), file or folder only (optional) |
|
|
94
|
+
| `ask_date` | `date` | one or more formats, optional `min_date` / `max_date` |
|
|
95
|
+
|
|
96
|
+
Invalid arguments from the programmer (for example `min_value` greater than `max_value`)
|
|
97
|
+
raise a `ValueError` immediately instead of asking the user forever.
|
|
98
|
+
|
|
99
|
+
`Ctrl+C` and `Ctrl+D` are not caught, so your program can handle them the way it wants
|
|
100
|
+
(`KeyboardInterrupt` and `EOFError`).
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
askr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# askr
|
|
2
|
+
|
|
3
|
+
Simple, validated input prompts for the terminal.
|
|
4
|
+
|
|
5
|
+
Every function keeps asking until the answer is valid, so you never have to write
|
|
6
|
+
another `while True` / `try` / `except ValueError` loop around `input()` again.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install askr
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.10 or newer. No dependencies.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from datetime import date
|
|
20
|
+
from askr import ask_choice, ask_date, ask_float, ask_int, ask_password, ask_path, ask_str, ask_yn
|
|
21
|
+
|
|
22
|
+
age = ask_int("Your age: ", min_value=0, max_value=120)
|
|
23
|
+
price = ask_float("Price: ", min_value=0)
|
|
24
|
+
name = ask_str("Your name: ", min_length=1, max_length=40)
|
|
25
|
+
|
|
26
|
+
if ask_yn("Continue?"): # Enter = yes
|
|
27
|
+
...
|
|
28
|
+
if ask_yn("Delete everything?", default=False): # Enter = no
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
mode = ask_choice("Difficulty?", ["Easy", "Medium", "Hard"], default="Medium")
|
|
32
|
+
|
|
33
|
+
password = ask_password(confirm=True, min_length=8, require_upper=True,
|
|
34
|
+
require_digit=True, require_special=True)
|
|
35
|
+
pin = ask_password("PIN: ", pattern=r"\d{4}", pattern_hint="be exactly 4 digits")
|
|
36
|
+
|
|
37
|
+
file = ask_path("File to open: ", kind="file")
|
|
38
|
+
birthday = ask_date("Birthday: ", ("%Y-%m-%d", "%d.%m.%Y"), max_date=date.today())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
What the user sees:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Your age: abc
|
|
45
|
+
Invalid entry. Please enter a whole number.
|
|
46
|
+
Your age: 200
|
|
47
|
+
The number must be between 0 and 120.
|
|
48
|
+
|
|
49
|
+
Difficulty?
|
|
50
|
+
1. Easy
|
|
51
|
+
2. Medium (default)
|
|
52
|
+
3. Hard
|
|
53
|
+
Your choice: hard
|
|
54
|
+
|
|
55
|
+
Password:
|
|
56
|
+
The password must:
|
|
57
|
+
- contain an uppercase letter
|
|
58
|
+
- contain a special character
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Functions
|
|
62
|
+
|
|
63
|
+
| Function | Returns | Validates |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `ask_int` | `int` | whole number, optional `min_value` / `max_value` |
|
|
66
|
+
| `ask_float` | `float` | finite number, optional `min_value` / `max_value` |
|
|
67
|
+
| `ask_str` | `str` | optional `min_length` / `max_length`, strips whitespace by default |
|
|
68
|
+
| `ask_yn` | `bool` | y / yes / n / no, Enter returns `default` |
|
|
69
|
+
| `ask_choice` | `str` | option number or name (case-insensitive), optional `default` |
|
|
70
|
+
| `ask_password` | `str` | hidden input, optional confirmation, length, character classes, regex |
|
|
71
|
+
| `ask_path` | `Path` | must exist (optional), file or folder only (optional) |
|
|
72
|
+
| `ask_date` | `date` | one or more formats, optional `min_date` / `max_date` |
|
|
73
|
+
|
|
74
|
+
Invalid arguments from the programmer (for example `min_value` greater than `max_value`)
|
|
75
|
+
raise a `ValueError` immediately instead of asking the user forever.
|
|
76
|
+
|
|
77
|
+
`Ctrl+C` and `Ctrl+D` are not caught, so your program can handle them the way it wants
|
|
78
|
+
(`KeyboardInterrupt` and `EOFError`).
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
askr-0.1.0/main.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "askr"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Simple, validated input prompts for the terminal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Prevolut Ltd" }]
|
|
14
|
+
keywords = ["input", "prompt", "cli", "terminal", "validation"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Simple, validated input prompts for the terminal."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
ask_choice,
|
|
5
|
+
ask_date,
|
|
6
|
+
ask_float,
|
|
7
|
+
ask_int,
|
|
8
|
+
ask_password,
|
|
9
|
+
ask_path,
|
|
10
|
+
ask_str,
|
|
11
|
+
ask_yn,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ask_choice",
|
|
16
|
+
"ask_date",
|
|
17
|
+
"ask_float",
|
|
18
|
+
"ask_int",
|
|
19
|
+
"ask_password",
|
|
20
|
+
"ask_path",
|
|
21
|
+
"ask_str",
|
|
22
|
+
"ask_yn",
|
|
23
|
+
]
|
|
24
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Simple, validated input prompts for the terminal."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from getpass import getpass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal, TypeVar
|
|
11
|
+
|
|
12
|
+
# Reference date used to show the user an example for each accepted format.
|
|
13
|
+
# Day 27 cannot be a month, so day and month are never confused in the example.
|
|
14
|
+
_EXAMPLE_DATE = date(2026, 9, 27)
|
|
15
|
+
|
|
16
|
+
_Number = TypeVar("_Number", int, float)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ask_yn(prompt: str, default: bool = True) -> bool:
|
|
20
|
+
"""Ask a yes/no question until a valid answer is given.
|
|
21
|
+
|
|
22
|
+
Accepts "y", "yes", "n" and "no" (case-insensitive).
|
|
23
|
+
Pressing Enter without input returns the default.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
prompt: The question shown to the user.
|
|
27
|
+
default: The value returned when the user just presses Enter.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True for yes, False for no.
|
|
31
|
+
"""
|
|
32
|
+
hint = "Y/n: " if default else "y/N: "
|
|
33
|
+
while True:
|
|
34
|
+
answer = input(f"{prompt} {hint}").strip().lower()
|
|
35
|
+
if answer == "":
|
|
36
|
+
return default
|
|
37
|
+
if answer in ("y", "yes"):
|
|
38
|
+
return True
|
|
39
|
+
if answer in ("n", "no"):
|
|
40
|
+
return False
|
|
41
|
+
print("Please answer with yes or no.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ask_int(prompt: str, min_value: int | None = None, max_value: int | None = None) -> int:
|
|
45
|
+
"""Ask for a whole number until a valid one is entered.
|
|
46
|
+
|
|
47
|
+
Optionally restricts the input to a range. Both bounds are inclusive.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
prompt: The text shown to the user.
|
|
51
|
+
min_value: The smallest accepted number, or None for no lower bound.
|
|
52
|
+
max_value: The largest accepted number, or None for no upper bound.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The number entered by the user.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If min_value is greater than max_value.
|
|
59
|
+
"""
|
|
60
|
+
return _ask_number(prompt, int, "a whole number", min_value, max_value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ask_float(prompt: str, min_value: float | None = None, max_value: float | None = None) -> float:
|
|
64
|
+
"""Ask for a number (decimals allowed) until a valid one is entered.
|
|
65
|
+
|
|
66
|
+
Optionally restricts the input to a range. Both bounds are inclusive.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
prompt: The text shown to the user.
|
|
70
|
+
min_value: The smallest accepted number, or None for no lower bound.
|
|
71
|
+
max_value: The largest accepted number, or None for no upper bound.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The number entered by the user.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If min_value is greater than max_value.
|
|
78
|
+
"""
|
|
79
|
+
# "nan" and "inf" are valid for float() but never a sensible answer.
|
|
80
|
+
return _ask_number(prompt, float, "a number", min_value, max_value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ask_str(prompt: str, min_length: int | None = None, max_length: int | None = None,
|
|
84
|
+
strip: bool = True) -> str:
|
|
85
|
+
"""Ask for text until it meets the optional length requirements.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
prompt: The text shown to the user.
|
|
89
|
+
min_length: The minimum number of characters, or None for no limit.
|
|
90
|
+
Use 1 to disallow empty input.
|
|
91
|
+
max_length: The maximum number of characters, or None for no limit.
|
|
92
|
+
strip: Remove leading and trailing whitespace before checking.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The text entered by the user.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: If min_length is greater than max_length.
|
|
99
|
+
"""
|
|
100
|
+
_validate_bounds(min_length, max_length, "min_length", "max_length")
|
|
101
|
+
|
|
102
|
+
while True:
|
|
103
|
+
user_input = input(prompt)
|
|
104
|
+
if strip:
|
|
105
|
+
user_input = user_input.strip()
|
|
106
|
+
if not user_input and min_length:
|
|
107
|
+
print("Input cannot be empty.")
|
|
108
|
+
continue
|
|
109
|
+
if not _check_range(len(user_input), min_length, max_length):
|
|
110
|
+
print(f"Input must be {_range_hint(min_length, max_length)} characters long.")
|
|
111
|
+
continue
|
|
112
|
+
return user_input
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ask_choice(prompt: str, options: Sequence[str], default: str | None = None) -> str:
|
|
116
|
+
"""Show a numbered list and ask the user to pick one option.
|
|
117
|
+
|
|
118
|
+
The user can answer with the option's number or its name (case-insensitive).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
prompt: The question shown above the list.
|
|
122
|
+
options: The options to choose from.
|
|
123
|
+
default: An option returned when the user just presses Enter, or None.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The chosen option, exactly as given in options.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
TypeError: If options is a single string instead of a list.
|
|
130
|
+
ValueError: If options is empty or default is not one of the options.
|
|
131
|
+
"""
|
|
132
|
+
# A plain string is a Sequence[str] too, but would list single characters.
|
|
133
|
+
if isinstance(options, str):
|
|
134
|
+
raise TypeError("options must be a list of strings, not a single string")
|
|
135
|
+
if not options:
|
|
136
|
+
raise ValueError("options must not be empty")
|
|
137
|
+
if default is not None and default not in options:
|
|
138
|
+
raise ValueError("default must be one of the options")
|
|
139
|
+
|
|
140
|
+
print(prompt)
|
|
141
|
+
for number, option in enumerate(options, start=1):
|
|
142
|
+
marker = " (default)" if option == default else ""
|
|
143
|
+
print(f" {number}. {option}{marker}")
|
|
144
|
+
|
|
145
|
+
lookup = {option.lower(): option for option in options}
|
|
146
|
+
while True:
|
|
147
|
+
answer = input("Your choice: ").strip()
|
|
148
|
+
if answer == "" and default is not None:
|
|
149
|
+
return default
|
|
150
|
+
# Names are checked first, so an option called "2" is not mistaken for a number.
|
|
151
|
+
if answer.lower() in lookup:
|
|
152
|
+
return lookup[answer.lower()]
|
|
153
|
+
index = _parse_int(answer)
|
|
154
|
+
if index is not None and 1 <= index <= len(options):
|
|
155
|
+
return options[index - 1]
|
|
156
|
+
print(f"Please enter a number from 1 to {len(options)} or the name of an option.")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def ask_password(prompt: str = "Password: ", confirm: bool = False,
|
|
160
|
+
min_length: int | None = None,
|
|
161
|
+
require_upper: bool = False, require_lower: bool = False,
|
|
162
|
+
require_digit: bool = False, require_special: bool = False,
|
|
163
|
+
pattern: str | None = None, pattern_hint: str | None = None) -> str:
|
|
164
|
+
"""Ask for a password without showing it on screen.
|
|
165
|
+
|
|
166
|
+
All rules the password breaks are reported at once, so the user
|
|
167
|
+
can fix everything in one go.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
prompt: The text shown to the user.
|
|
171
|
+
confirm: Ask a second time and require both entries to match.
|
|
172
|
+
min_length: The minimum number of characters, or None for no limit.
|
|
173
|
+
require_upper: Require at least one uppercase letter.
|
|
174
|
+
require_lower: Require at least one lowercase letter.
|
|
175
|
+
require_digit: Require at least one digit.
|
|
176
|
+
require_special: Require at least one character that is neither
|
|
177
|
+
a letter, a digit nor whitespace (e.g. ! ? # _).
|
|
178
|
+
pattern: A custom regular expression the whole password must match.
|
|
179
|
+
pattern_hint: The message shown if pattern does not match.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The password entered by the user.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ValueError: If pattern is not a valid regular expression.
|
|
186
|
+
"""
|
|
187
|
+
rules: list[tuple[Callable[[str], bool], str]] = []
|
|
188
|
+
if min_length is not None:
|
|
189
|
+
rules.append((lambda p: len(p) >= min_length, f"be at least {min_length} characters long"))
|
|
190
|
+
if require_upper:
|
|
191
|
+
rules.append((lambda p: any(c.isupper() for c in p), "contain an uppercase letter"))
|
|
192
|
+
if require_lower:
|
|
193
|
+
rules.append((lambda p: any(c.islower() for c in p), "contain a lowercase letter"))
|
|
194
|
+
if require_digit:
|
|
195
|
+
rules.append((lambda p: any(c.isdigit() for c in p), "contain a digit"))
|
|
196
|
+
if require_special:
|
|
197
|
+
rules.append((lambda p: any(not c.isalnum() and not c.isspace() for c in p),
|
|
198
|
+
"contain a special character"))
|
|
199
|
+
if pattern is not None:
|
|
200
|
+
try:
|
|
201
|
+
compiled = re.compile(pattern)
|
|
202
|
+
except re.error as error:
|
|
203
|
+
raise ValueError(f"pattern is not a valid regular expression: {error}") from None
|
|
204
|
+
rules.append((lambda p: compiled.fullmatch(p) is not None,
|
|
205
|
+
pattern_hint or "match the required format"))
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
password = getpass(prompt)
|
|
209
|
+
broken = [message for check, message in rules if not check(password)]
|
|
210
|
+
if broken:
|
|
211
|
+
print("The password must:")
|
|
212
|
+
for message in broken:
|
|
213
|
+
print(f" - {message}")
|
|
214
|
+
continue
|
|
215
|
+
if confirm and getpass("Repeat password: ") != password:
|
|
216
|
+
print("The passwords do not match. Please try again.")
|
|
217
|
+
continue
|
|
218
|
+
return password
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def ask_path(prompt: str, must_exist: bool = True,
|
|
222
|
+
kind: Literal["any", "file", "dir"] = "any") -> Path:
|
|
223
|
+
"""Ask for a file system path until a valid one is entered.
|
|
224
|
+
|
|
225
|
+
Surrounding quotes and a leading "~" are handled, so paths pasted or
|
|
226
|
+
dragged in from a file manager work as expected.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
prompt: The text shown to the user.
|
|
230
|
+
must_exist: Require the path to exist.
|
|
231
|
+
kind: "file" to accept only files, "dir" to accept only folders,
|
|
232
|
+
"any" to accept both. Only checked if the path exists.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The entered path, with "~" expanded.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValueError: If kind is not "any", "file" or "dir".
|
|
239
|
+
"""
|
|
240
|
+
if kind not in ("any", "file", "dir"):
|
|
241
|
+
raise ValueError('kind must be "any", "file" or "dir"')
|
|
242
|
+
|
|
243
|
+
while True:
|
|
244
|
+
raw = input(prompt).strip().strip("'\"")
|
|
245
|
+
if not raw:
|
|
246
|
+
print("Please enter a path.")
|
|
247
|
+
continue
|
|
248
|
+
# Terminals on macOS and Linux escape spaces as "\ " on drag and drop.
|
|
249
|
+
# On Windows a backslash is the path separator and must stay untouched.
|
|
250
|
+
if os.name != "nt":
|
|
251
|
+
raw = raw.replace("\\ ", " ")
|
|
252
|
+
path = Path(raw).expanduser()
|
|
253
|
+
if must_exist and not path.exists():
|
|
254
|
+
print("This path does not exist. Please try again.")
|
|
255
|
+
continue
|
|
256
|
+
if path.exists() and kind == "file" and not path.is_file():
|
|
257
|
+
print("This is not a file. Please enter the path of a file.")
|
|
258
|
+
continue
|
|
259
|
+
if path.exists() and kind == "dir" and not path.is_dir():
|
|
260
|
+
print("This is not a folder. Please enter the path of a folder.")
|
|
261
|
+
continue
|
|
262
|
+
return path
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def ask_date(prompt: str, formats: str | Sequence[str] = "%Y-%m-%d",
|
|
266
|
+
min_date: date | None = None, max_date: date | None = None) -> date:
|
|
267
|
+
"""Ask for a date until a valid one in one of the accepted formats is entered.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
prompt: The text shown to the user.
|
|
271
|
+
formats: One strftime format or several, tried in order,
|
|
272
|
+
e.g. ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y").
|
|
273
|
+
min_date: The earliest accepted date, or None for no limit.
|
|
274
|
+
max_date: The latest accepted date, or None for no limit.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The entered date.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If formats is empty or min_date is later than max_date.
|
|
281
|
+
"""
|
|
282
|
+
if isinstance(formats, str):
|
|
283
|
+
formats = (formats,)
|
|
284
|
+
if not formats:
|
|
285
|
+
raise ValueError("formats must not be empty")
|
|
286
|
+
_validate_bounds(min_date, max_date, "min_date", "max_date")
|
|
287
|
+
|
|
288
|
+
examples = " or ".join(_EXAMPLE_DATE.strftime(fmt) for fmt in formats)
|
|
289
|
+
while True:
|
|
290
|
+
value = _parse_date(input(prompt).strip(), formats)
|
|
291
|
+
if value is None:
|
|
292
|
+
print(f"Invalid date. Please use a format like {examples}.")
|
|
293
|
+
continue
|
|
294
|
+
if not _check_range(value, min_date, max_date):
|
|
295
|
+
hint = _range_hint(min_date, max_date, at_least="on or after", at_most="on or before")
|
|
296
|
+
print(f"The date must be {hint}.")
|
|
297
|
+
continue
|
|
298
|
+
return value
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _ask_number(prompt: str, converter: Callable[[str], _Number], type_name: str,
|
|
302
|
+
min_value: float | None = None, max_value: float | None = None) -> _Number:
|
|
303
|
+
"""Ask for a number using the given converter until a valid one is entered."""
|
|
304
|
+
_validate_bounds(min_value, max_value, "min_value", "max_value")
|
|
305
|
+
|
|
306
|
+
while True:
|
|
307
|
+
try:
|
|
308
|
+
value = converter(input(prompt))
|
|
309
|
+
except ValueError:
|
|
310
|
+
print(f"Invalid entry. Please enter {type_name}.")
|
|
311
|
+
continue
|
|
312
|
+
if not math.isfinite(value):
|
|
313
|
+
print(f"Invalid entry. Please enter {type_name}.")
|
|
314
|
+
continue
|
|
315
|
+
if not _check_range(value, min_value, max_value):
|
|
316
|
+
print(f"The number must be {_range_hint(min_value, max_value)}.")
|
|
317
|
+
continue
|
|
318
|
+
return value
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_int(text: str) -> int | None:
|
|
322
|
+
"""Return text as an int, or None if it is not a whole number."""
|
|
323
|
+
try:
|
|
324
|
+
return int(text)
|
|
325
|
+
except ValueError:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _parse_date(text: str, formats: Sequence[str]) -> date | None:
|
|
330
|
+
"""Return the date for the first matching format, or None if none match."""
|
|
331
|
+
for fmt in formats:
|
|
332
|
+
try:
|
|
333
|
+
return datetime.strptime(text, fmt).date()
|
|
334
|
+
except ValueError:
|
|
335
|
+
continue
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _validate_bounds(min_value: Any, max_value: Any, min_name: str, max_name: str) -> None:
|
|
340
|
+
"""Raise ValueError if the lower bound is greater than the upper bound."""
|
|
341
|
+
if min_value is not None and max_value is not None and min_value > max_value:
|
|
342
|
+
raise ValueError(f"{min_name} must not be greater than {max_name}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _check_range(value: Any, min_value: Any, max_value: Any) -> bool:
|
|
346
|
+
"""Return True if value lies within the optional bounds."""
|
|
347
|
+
return (min_value is None or value >= min_value) and (max_value is None or value <= max_value)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _range_hint(min_value: Any, max_value: Any,
|
|
351
|
+
at_least: str = "at least", at_most: str = "at most") -> str:
|
|
352
|
+
"""Describe a range in words, e.g. 'at least 3' or 'between 1 and 10'.
|
|
353
|
+
|
|
354
|
+
at_least and at_most replace the wording for one-sided ranges,
|
|
355
|
+
e.g. "on or after" and "on or before" for dates.
|
|
356
|
+
"""
|
|
357
|
+
if min_value is None:
|
|
358
|
+
return f"{at_most} {max_value}"
|
|
359
|
+
if max_value is None:
|
|
360
|
+
return f"{at_least} {min_value}"
|
|
361
|
+
return f"between {min_value} and {max_value}"
|
|
File without changes
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Tests for askr. Run with: pytest"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import askr
|
|
9
|
+
from askr import core
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def feed(monkeypatch: pytest.MonkeyPatch, answers: Iterable[str]) -> None:
|
|
13
|
+
"""Replace input() and getpass() with a fixed list of answers."""
|
|
14
|
+
it = iter(answers)
|
|
15
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": next(it))
|
|
16
|
+
monkeypatch.setattr(core, "getpass", lambda prompt="": next(it))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ask_yn
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize(("answers", "default", "expected"), [
|
|
22
|
+
(["y"], True, True),
|
|
23
|
+
(["YES"], False, True),
|
|
24
|
+
(["n"], True, False),
|
|
25
|
+
([" No "], True, False),
|
|
26
|
+
([""], True, True),
|
|
27
|
+
([""], False, False),
|
|
28
|
+
(["maybe", "y"], True, True),
|
|
29
|
+
])
|
|
30
|
+
def test_ask_yn(monkeypatch, answers, default, expected):
|
|
31
|
+
feed(monkeypatch, answers)
|
|
32
|
+
assert askr.ask_yn("?", default=default) is expected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ask_int / ask_float
|
|
36
|
+
|
|
37
|
+
def test_ask_int_retries_until_valid(monkeypatch, capsys):
|
|
38
|
+
feed(monkeypatch, ["abc", "", "1.5", "0", "11", "7"])
|
|
39
|
+
assert askr.ask_int("n: ", min_value=1, max_value=10) == 7
|
|
40
|
+
out = capsys.readouterr().out
|
|
41
|
+
assert "whole number" in out
|
|
42
|
+
assert "between 1 and 10" in out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_ask_int_one_sided_bounds(monkeypatch, capsys):
|
|
46
|
+
feed(monkeypatch, ["-1", "5"])
|
|
47
|
+
assert askr.ask_int("n: ", min_value=0) == 5
|
|
48
|
+
assert "at least 0" in capsys.readouterr().out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_ask_int_returns_int(monkeypatch):
|
|
52
|
+
feed(monkeypatch, [" 42 "])
|
|
53
|
+
value = askr.ask_int("n: ")
|
|
54
|
+
assert value == 42
|
|
55
|
+
assert isinstance(value, int)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_ask_float_rejects_nan_and_inf(monkeypatch):
|
|
59
|
+
feed(monkeypatch, ["nan", "inf", "-inf", "2.5"])
|
|
60
|
+
assert askr.ask_float("x: ") == 2.5
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_number_bounds_are_checked(monkeypatch):
|
|
64
|
+
with pytest.raises(ValueError, match="min_value"):
|
|
65
|
+
askr.ask_int("n: ", min_value=10, max_value=1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ask_str
|
|
69
|
+
|
|
70
|
+
def test_ask_str_length(monkeypatch, capsys):
|
|
71
|
+
feed(monkeypatch, [" ", "toolong", "ok"])
|
|
72
|
+
assert askr.ask_str("s: ", min_length=1, max_length=4) == "ok"
|
|
73
|
+
out = capsys.readouterr().out
|
|
74
|
+
assert "cannot be empty" in out
|
|
75
|
+
assert "between 1 and 4" in out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_ask_str_without_strip(monkeypatch):
|
|
79
|
+
feed(monkeypatch, [" hi "])
|
|
80
|
+
assert askr.ask_str("s: ", strip=False) == " hi "
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_ask_str_allows_empty_by_default(monkeypatch):
|
|
84
|
+
feed(monkeypatch, [""])
|
|
85
|
+
assert askr.ask_str("s: ") == ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ask_choice
|
|
89
|
+
|
|
90
|
+
OPTIONS = ["Easy", "Medium", "Hard"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize(("answers", "expected"), [
|
|
94
|
+
(["1"], "Easy"),
|
|
95
|
+
(["hard"], "Hard"),
|
|
96
|
+
(["0", "4", "x", "²", "2"], "Medium"),
|
|
97
|
+
])
|
|
98
|
+
def test_ask_choice(monkeypatch, answers, expected):
|
|
99
|
+
feed(monkeypatch, answers)
|
|
100
|
+
assert askr.ask_choice("?", OPTIONS) == expected
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_ask_choice_default(monkeypatch):
|
|
104
|
+
feed(monkeypatch, [""])
|
|
105
|
+
assert askr.ask_choice("?", OPTIONS, default="Medium") == "Medium"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_ask_choice_prefers_names_over_numbers(monkeypatch):
|
|
109
|
+
feed(monkeypatch, ["2"])
|
|
110
|
+
assert askr.ask_choice("?", ["2", "1"]) == "2"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_ask_choice_invalid_arguments():
|
|
114
|
+
with pytest.raises(ValueError):
|
|
115
|
+
askr.ask_choice("?", [])
|
|
116
|
+
with pytest.raises(ValueError):
|
|
117
|
+
askr.ask_choice("?", OPTIONS, default="Nightmare")
|
|
118
|
+
with pytest.raises(TypeError):
|
|
119
|
+
askr.ask_choice("?", "abc")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ask_password
|
|
123
|
+
|
|
124
|
+
def test_ask_password_reports_all_broken_rules(monkeypatch, capsys):
|
|
125
|
+
feed(monkeypatch, ["abc", "Abcdefg1!", "Abcdefg1!"])
|
|
126
|
+
result = askr.ask_password(confirm=True, min_length=8, require_upper=True,
|
|
127
|
+
require_lower=True, require_digit=True,
|
|
128
|
+
require_special=True)
|
|
129
|
+
assert result == "Abcdefg1!"
|
|
130
|
+
out = capsys.readouterr().out
|
|
131
|
+
for rule in ("8 characters", "uppercase", "digit", "special"):
|
|
132
|
+
assert rule in out
|
|
133
|
+
assert "lowercase" not in out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_ask_password_confirmation_mismatch(monkeypatch, capsys):
|
|
137
|
+
feed(monkeypatch, ["secret", "typo", "secret", "secret"])
|
|
138
|
+
assert askr.ask_password(confirm=True) == "secret"
|
|
139
|
+
assert "do not match" in capsys.readouterr().out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_ask_password_umlaut_counts_as_letter(monkeypatch):
|
|
143
|
+
feed(monkeypatch, ["Äbc"])
|
|
144
|
+
assert askr.ask_password(require_upper=True) == "Äbc"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_ask_password_pattern(monkeypatch, capsys):
|
|
148
|
+
feed(monkeypatch, ["12345", "abcd", "1234"])
|
|
149
|
+
assert askr.ask_password(pattern=r"\d{4}", pattern_hint="be 4 digits") == "1234"
|
|
150
|
+
assert "be 4 digits" in capsys.readouterr().out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_ask_password_invalid_pattern():
|
|
154
|
+
with pytest.raises(ValueError, match="regular expression"):
|
|
155
|
+
askr.ask_password(pattern="[")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ask_path
|
|
159
|
+
|
|
160
|
+
def test_ask_path_kinds(monkeypatch, tmp_path):
|
|
161
|
+
file = tmp_path / "my file.txt"
|
|
162
|
+
file.write_text("x")
|
|
163
|
+
folder = tmp_path / "sub"
|
|
164
|
+
folder.mkdir()
|
|
165
|
+
|
|
166
|
+
feed(monkeypatch, ["", str(tmp_path / "missing"), str(folder), f"'{file}'"])
|
|
167
|
+
assert askr.ask_path("f: ", kind="file") == file
|
|
168
|
+
|
|
169
|
+
feed(monkeypatch, [str(file), str(folder)])
|
|
170
|
+
assert askr.ask_path("d: ", kind="dir") == folder
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_ask_path_new_file(monkeypatch, tmp_path):
|
|
174
|
+
target = tmp_path / "new.txt"
|
|
175
|
+
feed(monkeypatch, [str(target)])
|
|
176
|
+
assert askr.ask_path("f: ", must_exist=False, kind="file") == target
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_ask_path_expands_home(monkeypatch):
|
|
180
|
+
feed(monkeypatch, ["~"])
|
|
181
|
+
assert askr.ask_path("p: ").is_absolute()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_ask_path_invalid_kind():
|
|
185
|
+
with pytest.raises(ValueError):
|
|
186
|
+
askr.ask_path("p: ", kind="folder") # type: ignore[arg-type]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ask_date
|
|
190
|
+
|
|
191
|
+
def test_ask_date_formats(monkeypatch, capsys):
|
|
192
|
+
feed(monkeypatch, ["27/09/2026", "2026-02-30", "27.09.2026"])
|
|
193
|
+
assert askr.ask_date("d: ", ("%Y-%m-%d", "%d.%m.%Y")) == date(2026, 9, 27)
|
|
194
|
+
assert "2026-09-27 or 27.09.2026" in capsys.readouterr().out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize(("kwargs", "message"), [
|
|
198
|
+
({"min_date": date(2025, 1, 1)}, "on or after 2025-01-01"),
|
|
199
|
+
({"max_date": date(2020, 1, 1)}, "on or before 2020-01-01"),
|
|
200
|
+
({"min_date": date(2025, 1, 1), "max_date": date(2025, 12, 31)},
|
|
201
|
+
"between 2025-01-01 and 2025-12-31"),
|
|
202
|
+
])
|
|
203
|
+
def test_ask_date_range_messages(monkeypatch, capsys, kwargs, message):
|
|
204
|
+
feed(monkeypatch, ["2024-06-15", "2019-06-15", "2025-06-15"])
|
|
205
|
+
askr.ask_date("d: ", **kwargs)
|
|
206
|
+
assert message in capsys.readouterr().out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_ask_date_invalid_arguments():
|
|
210
|
+
with pytest.raises(ValueError):
|
|
211
|
+
askr.ask_date("d: ", formats=())
|
|
212
|
+
with pytest.raises(ValueError, match="min_date"):
|
|
213
|
+
askr.ask_date("d: ", min_date=date(2026, 1, 2), max_date=date(2026, 1, 1))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# package
|
|
217
|
+
|
|
218
|
+
def test_public_api():
|
|
219
|
+
assert sorted(askr.__all__) == sorted(
|
|
220
|
+
name for name in dir(askr) if name.startswith("ask_")
|
|
221
|
+
)
|