get-min-py 1.0.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.
- get_min_py-1.0.0/PKG-INFO +118 -0
- get_min_py-1.0.0/docs/LICENSE.md +21 -0
- get_min_py-1.0.0/docs/README.md +65 -0
- get_min_py-1.0.0/pyproject.toml +106 -0
- get_min_py-1.0.0/setup.cfg +4 -0
- get_min_py-1.0.0/src/get_min_py/__init__.py +7 -0
- get_min_py-1.0.0/src/get_min_py/api.py +51 -0
- get_min_py-1.0.0/src/get_min_py/cli/__init__.py +0 -0
- get_min_py-1.0.0/src/get_min_py/cli/__main__.py +31 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/data/__init__.py +3 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/data/csv.py +5 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/data/file.py +23 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/data/json.py +55 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/data/sns.py +8 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/env.py +15 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/init.py +12 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/jsdelivr.py +9 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/language.py +103 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/log.py +101 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/pkg.py +6 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/settings.py +104 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/string.py +2 -0
- get_min_py-1.0.0/src/get_min_py/cli/lib/url.py +33 -0
- get_min_py-1.0.0/src/get_min_py/data/_locales/en/messages.json +17 -0
- get_min_py-1.0.0/src/get_min_py/data/package_data.json +26 -0
- get_min_py-1.0.0/src/get_min_py.egg-info/PKG-INFO +118 -0
- get_min_py-1.0.0/src/get_min_py.egg-info/SOURCES.txt +29 -0
- get_min_py-1.0.0/src/get_min_py.egg-info/dependency_links.txt +1 -0
- get_min_py-1.0.0/src/get_min_py.egg-info/entry_points.txt +9 -0
- get_min_py-1.0.0/src/get_min_py.egg-info/requires.txt +12 -0
- get_min_py-1.0.0/src/get_min_py.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: get-min-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Get the minimum Python version required for a PyPI package.
|
|
5
|
+
Author-email: Adam Lui <adam@kudoai.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Changelog, https://github.com/adamlui/python-utils/releases/tag/get-min-py-1.0.0
|
|
8
|
+
Project-URL: Documentation, https://github.com/adamlui/python-utils/tree/main/get-min-py/docs
|
|
9
|
+
Project-URL: Funding, https://github.com/sponsors/adamlui
|
|
10
|
+
Project-URL: Homepage, https://github.com/adamlui/python-utils/tree/main/get-min-py/#readme
|
|
11
|
+
Project-URL: Issues, https://github.com/adamlui/python-utils/issues
|
|
12
|
+
Project-URL: PyPI Stats, https://pepy.tech/projects/get-min-py
|
|
13
|
+
Project-URL: Releases, https://github.com/adamlui/python-utils/releases
|
|
14
|
+
Project-URL: Repository, https://github.com/adamlui/python-utils
|
|
15
|
+
Keywords: api,auto-detect,classifiers,cli,compatibility,console,dev-tool,minimum-version,package-metadata,pypi,python-version,requires-python,version-check
|
|
16
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Intended Audience :: Information Technology
|
|
20
|
+
Classifier: Intended Audience :: System Administrators
|
|
21
|
+
Classifier: Natural Language :: English
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Programming Language :: Python
|
|
24
|
+
Classifier: Programming Language :: Python :: 3
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
34
|
+
Classifier: Topic :: Software Development
|
|
35
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
36
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
37
|
+
Classifier: Topic :: Utilities
|
|
38
|
+
Classifier: Topic :: Internet
|
|
39
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
40
|
+
Requires-Python: <4,>=3.8
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: docs/LICENSE.md
|
|
43
|
+
Requires-Dist: colorama~=0.4.6; platform_system == "Windows"
|
|
44
|
+
Requires-Dist: json5~=0.13.0
|
|
45
|
+
Requires-Dist: ucs-detect~=2.0.2
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: nox>=2026.2.9; extra == "dev"
|
|
48
|
+
Requires-Dist: remove-json-keys~=1.8.3; extra == "dev"
|
|
49
|
+
Requires-Dist: tomli~=2.4.0; extra == "dev"
|
|
50
|
+
Requires-Dist: tomli-w~=1.2.0; extra == "dev"
|
|
51
|
+
Requires-Dist: translate-messages~=1.8.3; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
<a id="top"></a>
|
|
55
|
+
|
|
56
|
+
# > get-min-py
|
|
57
|
+
|
|
58
|
+
<a href="https://github.com/adamlui/python-utils/releases/tag/get-min-py-1.0.0">
|
|
59
|
+
<img height=31 src="https://img.shields.io/badge/Latest_Build-1.0.0-32fcee.svg?logo=icinga&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
60
|
+
<a href="https://github.com/adamlui/python-utils/blob/main/get-min-py/docs/LICENSE.md">
|
|
61
|
+
<img height=31 src="https://img.shields.io/badge/License-MIT-f99b27.svg?logo=internetarchive&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
62
|
+
<a href="https://www.codefactor.io/repository/github/adamlui/python-utils">
|
|
63
|
+
<img height=31 src="https://img.shields.io/codefactor/grade/github/adamlui/python-utils?label=Code+Quality&logo=codefactor&logoColor=white&labelColor=464646&color=a0fc55&style=for-the-badge"></a>
|
|
64
|
+
<a href="https://sonarcloud.io/component_measures?metric=new_vulnerabilities&id=adamlui_python-utils">
|
|
65
|
+
<img height=31 src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fsonarcloud.io%2Fapi%2Fmeasures%2Fcomponent%3Fcomponent%3Dadamlui_python-utils%26metricKeys%3Dvulnerabilities&query=%24.component.measures.0.value&style=for-the-badge&logo=sonarcloud&logoColor=white&labelColor=464646&label=Vulnerabilities&color=fafc74"></a>
|
|
66
|
+
|
|
67
|
+
> ### _Get the minimum Python version required for a PyPI package._
|
|
68
|
+
|
|
69
|
+
Uses `python-requires`, or classifiers if not found.
|
|
70
|
+
|
|
71
|
+
## ⚡ Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install get-min-py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 💻 Command line usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
get-min-py <pkg>[,pkg_b,pkg_c] # or check-min-py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
|
|
85
|
+
<img src="https://cdn.jsdelivr.net/gh/adamlui/python-utils@97a74a8/get-min-py/assets/images/cli-output.png">
|
|
86
|
+
|
|
87
|
+
CLI options:
|
|
88
|
+
|
|
89
|
+
| Option | Description
|
|
90
|
+
| --------------------------------- | -----------------
|
|
91
|
+
| `-h`, `--help` | Show help screen
|
|
92
|
+
| `-v`, `--version` | Show version
|
|
93
|
+
| `-V`, `--debug [targetConfigKey]` | Show debug logs
|
|
94
|
+
| `--docs` | Open docs URL
|
|
95
|
+
|
|
96
|
+
## 🔌 API usage
|
|
97
|
+
|
|
98
|
+
```py
|
|
99
|
+
import get_min_py
|
|
100
|
+
|
|
101
|
+
# Single package
|
|
102
|
+
result = get_min_py('requests')
|
|
103
|
+
print(result) # '3.9'
|
|
104
|
+
|
|
105
|
+
# Multiple packages
|
|
106
|
+
results = get_min_py(['numpy', 'pandas', 'flask'])
|
|
107
|
+
print(results) # ['3.11', '3.11', '3.9']
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
_Note: Most type checkers will falsely warn_ `get_min_py` _is not a callable module because they are incapable of analyzing runtime behavior (where the module is replaced w/ a function for cleaner, direct access). You can safely suppress such warnings using_ `# type: ignore`.
|
|
111
|
+
|
|
112
|
+
## MIT License
|
|
113
|
+
|
|
114
|
+
Copyright © 2023–2026 [Adam Lui](https://github.com/adamlui).
|
|
115
|
+
|
|
116
|
+
#
|
|
117
|
+
|
|
118
|
+
<a href="#top">Back to top ↑</a>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 🏛️ MIT License
|
|
2
|
+
|
|
3
|
+
**Copyright © 2026 [Adam Lui](https://github.com/adamlui)**
|
|
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.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<a id="top"></a>
|
|
2
|
+
|
|
3
|
+
# > get-min-py
|
|
4
|
+
|
|
5
|
+
<a href="https://github.com/adamlui/python-utils/releases/tag/get-min-py-1.0.0">
|
|
6
|
+
<img height=31 src="https://img.shields.io/badge/Latest_Build-1.0.0-32fcee.svg?logo=icinga&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
7
|
+
<a href="https://github.com/adamlui/python-utils/blob/main/get-min-py/docs/LICENSE.md">
|
|
8
|
+
<img height=31 src="https://img.shields.io/badge/License-MIT-f99b27.svg?logo=internetarchive&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
9
|
+
<a href="https://www.codefactor.io/repository/github/adamlui/python-utils">
|
|
10
|
+
<img height=31 src="https://img.shields.io/codefactor/grade/github/adamlui/python-utils?label=Code+Quality&logo=codefactor&logoColor=white&labelColor=464646&color=a0fc55&style=for-the-badge"></a>
|
|
11
|
+
<a href="https://sonarcloud.io/component_measures?metric=new_vulnerabilities&id=adamlui_python-utils">
|
|
12
|
+
<img height=31 src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fsonarcloud.io%2Fapi%2Fmeasures%2Fcomponent%3Fcomponent%3Dadamlui_python-utils%26metricKeys%3Dvulnerabilities&query=%24.component.measures.0.value&style=for-the-badge&logo=sonarcloud&logoColor=white&labelColor=464646&label=Vulnerabilities&color=fafc74"></a>
|
|
13
|
+
|
|
14
|
+
> ### _Get the minimum Python version required for a PyPI package._
|
|
15
|
+
|
|
16
|
+
Uses `python-requires`, or classifiers if not found.
|
|
17
|
+
|
|
18
|
+
## ⚡ Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install get-min-py
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 💻 Command line usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
get-min-py <pkg>[,pkg_b,pkg_c] # or check-min-py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
|
|
32
|
+
<img src="https://cdn.jsdelivr.net/gh/adamlui/python-utils@97a74a8/get-min-py/assets/images/cli-output.png">
|
|
33
|
+
|
|
34
|
+
CLI options:
|
|
35
|
+
|
|
36
|
+
| Option | Description
|
|
37
|
+
| --------------------------------- | -----------------
|
|
38
|
+
| `-h`, `--help` | Show help screen
|
|
39
|
+
| `-v`, `--version` | Show version
|
|
40
|
+
| `-V`, `--debug [targetConfigKey]` | Show debug logs
|
|
41
|
+
| `--docs` | Open docs URL
|
|
42
|
+
|
|
43
|
+
## 🔌 API usage
|
|
44
|
+
|
|
45
|
+
```py
|
|
46
|
+
import get_min_py
|
|
47
|
+
|
|
48
|
+
# Single package
|
|
49
|
+
result = get_min_py('requests')
|
|
50
|
+
print(result) # '3.9'
|
|
51
|
+
|
|
52
|
+
# Multiple packages
|
|
53
|
+
results = get_min_py(['numpy', 'pandas', 'flask'])
|
|
54
|
+
print(results) # ['3.11', '3.11', '3.9']
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
_Note: Most type checkers will falsely warn_ `get_min_py` _is not a callable module because they are incapable of analyzing runtime behavior (where the module is replaced w/ a function for cleaner, direct access). You can safely suppress such warnings using_ `# type: ignore`.
|
|
58
|
+
|
|
59
|
+
## MIT License
|
|
60
|
+
|
|
61
|
+
Copyright © 2023–2026 [Adam Lui](https://github.com/adamlui).
|
|
62
|
+
|
|
63
|
+
#
|
|
64
|
+
|
|
65
|
+
<a href="#top">Back to top ↑</a>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"setuptools~=82.0.0",
|
|
4
|
+
"wheel",
|
|
5
|
+
]
|
|
6
|
+
build-backend = "setuptools.build_meta"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "get-min-py"
|
|
10
|
+
version = "1.0.0"
|
|
11
|
+
description = "Get the minimum Python version required for a PyPI package."
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Adam Lui", email = "adam@kudoai.com" },
|
|
14
|
+
]
|
|
15
|
+
readme = "docs/README.md"
|
|
16
|
+
license = "MIT"
|
|
17
|
+
license-files = [
|
|
18
|
+
"docs/LICENSE.md",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"colorama~=0.4.6 ; platform_system == 'Windows'",
|
|
22
|
+
"json5~=0.13.0",
|
|
23
|
+
"ucs-detect~=2.0.2",
|
|
24
|
+
]
|
|
25
|
+
requires-python = ">=3.8,<4"
|
|
26
|
+
keywords = [
|
|
27
|
+
"api",
|
|
28
|
+
"auto-detect",
|
|
29
|
+
"classifiers",
|
|
30
|
+
"cli",
|
|
31
|
+
"compatibility",
|
|
32
|
+
"console",
|
|
33
|
+
"dev-tool",
|
|
34
|
+
"minimum-version",
|
|
35
|
+
"package-metadata",
|
|
36
|
+
"pypi",
|
|
37
|
+
"python-version",
|
|
38
|
+
"requires-python",
|
|
39
|
+
"version-check",
|
|
40
|
+
]
|
|
41
|
+
classifiers = [
|
|
42
|
+
"Development Status :: 5 - Production/Stable",
|
|
43
|
+
"Environment :: Console",
|
|
44
|
+
"Intended Audience :: Developers",
|
|
45
|
+
"Intended Audience :: Information Technology",
|
|
46
|
+
"Intended Audience :: System Administrators",
|
|
47
|
+
"Natural Language :: English",
|
|
48
|
+
"Operating System :: OS Independent",
|
|
49
|
+
"Programming Language :: Python",
|
|
50
|
+
"Programming Language :: Python :: 3",
|
|
51
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
52
|
+
"Programming Language :: Python :: 3.8",
|
|
53
|
+
"Programming Language :: Python :: 3.9",
|
|
54
|
+
"Programming Language :: Python :: 3.10",
|
|
55
|
+
"Programming Language :: Python :: 3.11",
|
|
56
|
+
"Programming Language :: Python :: 3.12",
|
|
57
|
+
"Programming Language :: Python :: 3.13",
|
|
58
|
+
"Programming Language :: Python :: 3.14",
|
|
59
|
+
"Programming Language :: Python :: 3.15",
|
|
60
|
+
"Topic :: Software Development",
|
|
61
|
+
"Topic :: Software Development :: Libraries",
|
|
62
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
63
|
+
"Topic :: Utilities",
|
|
64
|
+
"Topic :: Internet",
|
|
65
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[project.urls]
|
|
69
|
+
Changelog = "https://github.com/adamlui/python-utils/releases/tag/get-min-py-1.0.0"
|
|
70
|
+
Documentation = "https://github.com/adamlui/python-utils/tree/main/get-min-py/docs"
|
|
71
|
+
Funding = "https://github.com/sponsors/adamlui"
|
|
72
|
+
Homepage = "https://github.com/adamlui/python-utils/tree/main/get-min-py/#readme"
|
|
73
|
+
Issues = "https://github.com/adamlui/python-utils/issues"
|
|
74
|
+
"PyPI Stats" = "https://pepy.tech/projects/get-min-py"
|
|
75
|
+
Releases = "https://github.com/adamlui/python-utils/releases"
|
|
76
|
+
Repository = "https://github.com/adamlui/python-utils"
|
|
77
|
+
|
|
78
|
+
[project.scripts]
|
|
79
|
+
get-min-py = "get_min_py.cli.__main__:main"
|
|
80
|
+
get-min-ver = "get_min_py.cli.__main__:main"
|
|
81
|
+
check-min-py = "get_min_py.cli.__main__:main"
|
|
82
|
+
check-min-ver = "get_min_py.cli.__main__:main"
|
|
83
|
+
getminpy = "get_min_py.cli.__main__:main"
|
|
84
|
+
getminver = "get_min_py.cli.__main__:main"
|
|
85
|
+
checkminpy = "get_min_py.cli.__main__:main"
|
|
86
|
+
checkminver = "get_min_py.cli.__main__:main"
|
|
87
|
+
|
|
88
|
+
[project.optional-dependencies]
|
|
89
|
+
dev = [
|
|
90
|
+
"nox>=2026.2.9",
|
|
91
|
+
"remove-json-keys~=1.8.3",
|
|
92
|
+
"tomli~=2.4.0",
|
|
93
|
+
"tomli-w~=1.2.0",
|
|
94
|
+
"translate-messages~=1.8.3",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
[tool.setuptools.packages.find]
|
|
98
|
+
where = [
|
|
99
|
+
"src",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[tool.setuptools.package-data]
|
|
103
|
+
get_min_py = [
|
|
104
|
+
"data/*.json",
|
|
105
|
+
"data/_locales/en/messages.json",
|
|
106
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json, re, urllib.request as http
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Union, List, Optional
|
|
4
|
+
|
|
5
|
+
api = json.loads((Path(__file__).parent / 'data/package_data.json').read_text())
|
|
6
|
+
|
|
7
|
+
def get_min_py(pkg_names: Union[str, List[str]]) -> Union[Optional[str], List[Optional[str]]]:
|
|
8
|
+
if isinstance(pkg_names, str) : pkg_names = [pkg_names]
|
|
9
|
+
results: List[Optional[str]] = []
|
|
10
|
+
|
|
11
|
+
for pkg_name in pkg_names: # get min py
|
|
12
|
+
try:
|
|
13
|
+
req = http.Request(f'https://pypi.org/pypi/{pkg_name}/json')
|
|
14
|
+
req.add_header('User-Agent', f"{api['name']}/{api['version']}")
|
|
15
|
+
resp = http.urlopen(req, timeout=5)
|
|
16
|
+
pkg_info = json.loads(resp.read())['info']
|
|
17
|
+
|
|
18
|
+
# Check `requires_python`
|
|
19
|
+
requires_python = pkg_info.get('requires_python')
|
|
20
|
+
if requires_python:
|
|
21
|
+
ver_match = re.search(r'(>|>=|==|~=)\s*(\d+\.\d+(?:\.\d+)?)', requires_python)
|
|
22
|
+
if ver_match:
|
|
23
|
+
op, version = ver_match.group(1), ver_match.group(2)
|
|
24
|
+
if op == '>': # return minor-bumped
|
|
25
|
+
major, minor = version.split('.')[:2]
|
|
26
|
+
results.append(f'{major}.{int(minor) + 1}')
|
|
27
|
+
else: # >=|==|~=
|
|
28
|
+
results.append(version) # as-is
|
|
29
|
+
continue # to next pkg
|
|
30
|
+
|
|
31
|
+
# Check classifiers
|
|
32
|
+
classifiers, versions = pkg_info.get('classifiers', []), []
|
|
33
|
+
for classifier in classifiers:
|
|
34
|
+
if classifier.startswith('Programming Language :: Python ::'):
|
|
35
|
+
ver_match = re.search(r'(\d+(?:\.\d+)?)', classifier)
|
|
36
|
+
if ver_match : versions.append(ver_match.group())
|
|
37
|
+
if versions: # append lowest
|
|
38
|
+
decimal_vers = [ver for ver in versions if '.' in ver]
|
|
39
|
+
if decimal_vers:
|
|
40
|
+
decimal_vers.sort(key=lambda ver: [int(x) for x in ver.split('.')])
|
|
41
|
+
results.append(decimal_vers[0])
|
|
42
|
+
else:
|
|
43
|
+
results.append(min(versions, key=int))
|
|
44
|
+
continue # to next pkg
|
|
45
|
+
else : results.append(None)
|
|
46
|
+
|
|
47
|
+
except Exception as err:
|
|
48
|
+
print(f'Error fetching data for {pkg_name}: {err}')
|
|
49
|
+
results.append(None)
|
|
50
|
+
|
|
51
|
+
return results[0] if len(pkg_names) == 1 else results
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from ..api import get_min_py
|
|
4
|
+
from .lib import init, log, settings
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
if len(sys.argv) == 1 : sys.argv.append('--help')
|
|
8
|
+
cli = init.cli()
|
|
9
|
+
|
|
10
|
+
# Process early-exit args (e.g. --help, --version)
|
|
11
|
+
for ctrl_name, ctrl in vars(settings.controls).items():
|
|
12
|
+
if getattr(ctrl, 'exit', False) and getattr(cli.config, ctrl_name, False):
|
|
13
|
+
if hasattr(ctrl, 'handler') : ctrl.handler(cli)
|
|
14
|
+
sys.exit(0)
|
|
15
|
+
|
|
16
|
+
# Process pkgs
|
|
17
|
+
pkgs = []
|
|
18
|
+
for arg in sys.argv[1:]:
|
|
19
|
+
if arg.startswith('-') : continue
|
|
20
|
+
pkgs.extend([pkg.strip() for pkg in arg.split(',') if pkg.strip()])
|
|
21
|
+
if pkgs:
|
|
22
|
+
results = get_min_py(pkgs)
|
|
23
|
+
results = [results] if not isinstance(results, list) else results
|
|
24
|
+
for pkg, version in zip(pkgs, results):
|
|
25
|
+
if version:
|
|
26
|
+
log.info(f'{log.colors.bw}{pkg}:{log.colors.nc} Python {version}')
|
|
27
|
+
else:
|
|
28
|
+
log.dim(f'{pkg}: {cli.msgs.log_NO_REQ_FOUND}', no_newline=True)
|
|
29
|
+
log.line_break()
|
|
30
|
+
|
|
31
|
+
if __name__ == '__main__' : main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
def atomic_write(file_path: Union[Path, str], data: str, encoding: str ='utf-8') -> None: # to prevent TOCTOU
|
|
5
|
+
import os
|
|
6
|
+
file_path = Path(file_path)
|
|
7
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
tmp_path = file_path.parent / f'.{file_path.name}.tmp'
|
|
9
|
+
try:
|
|
10
|
+
with open(tmp_path, 'w', encoding=encoding) as file:
|
|
11
|
+
file.write(data) ; file.flush() ; os.fsync(file.fileno())
|
|
12
|
+
os.replace(tmp_path, file_path) # atomic rename
|
|
13
|
+
except Exception:
|
|
14
|
+
if tmp_path.exists() : tmp_path.unlink()
|
|
15
|
+
raise
|
|
16
|
+
|
|
17
|
+
def read(file_path: Union[Path, str], encoding: str = 'utf-8') -> str:
|
|
18
|
+
with open(file_path, 'r', encoding=encoding) as file:
|
|
19
|
+
return file.read()
|
|
20
|
+
|
|
21
|
+
def write(file_path: Union[Path, str], data: str, encoding: str = 'utf-8') -> None:
|
|
22
|
+
with open(file_path, 'w', encoding=encoding) as file:
|
|
23
|
+
file.write(data)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Union
|
|
4
|
+
|
|
5
|
+
import json5
|
|
6
|
+
|
|
7
|
+
def flatten(json: Dict[str, Any], key: str = 'message') -> Dict[str, Any]: # eliminate need to ref nested keys
|
|
8
|
+
flat_obj = {}
|
|
9
|
+
for json_key in json:
|
|
10
|
+
val = json[json_key]
|
|
11
|
+
flat_obj[json_key] = val[key] if isinstance(val, dict) and key in val else val
|
|
12
|
+
return flat_obj
|
|
13
|
+
|
|
14
|
+
def is_valid(file_path: Union[Path, str], format: str = 'json') -> bool:
|
|
15
|
+
file_path = Path(file_path)
|
|
16
|
+
if not file_path.exists():
|
|
17
|
+
return False
|
|
18
|
+
try : file_text = file_path.read_text(encoding='utf-8')
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
if format == 'json':
|
|
22
|
+
try : json.loads(file_text) ; return True
|
|
23
|
+
except Exception : return False
|
|
24
|
+
elif format == 'json5':
|
|
25
|
+
try : json5.loads(file_text) ; return True
|
|
26
|
+
except Exception : return False
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError(f"Unsupported format {format!r}. Expected 'json' or 'json5'")
|
|
29
|
+
|
|
30
|
+
def read(input: Union[Path, str], encoding: str = 'utf-8') -> Any:
|
|
31
|
+
input_str = str(input)
|
|
32
|
+
if input_str.endswith(('.json', '.json5')):
|
|
33
|
+
with open(input_str, 'r', encoding=encoding) as file:
|
|
34
|
+
return json5.load(file)
|
|
35
|
+
else : return json5.loads(input_str)
|
|
36
|
+
|
|
37
|
+
def write(file_path: Union[Path, str], data: Any, encoding: str = 'utf-8', ensure_ascii: bool = False,
|
|
38
|
+
style: str = 'pretty', atomic: bool =True):
|
|
39
|
+
from . import file
|
|
40
|
+
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
if style == 'pretty': # single key/val spans multi-lines
|
|
42
|
+
json_str = json.dumps(data, indent=2, ensure_ascii=ensure_ascii)
|
|
43
|
+
elif style == 'compact': # single key/val per line
|
|
44
|
+
lines = ['{']
|
|
45
|
+
items = list(data.items())
|
|
46
|
+
for idx, (key, val) in enumerate(items):
|
|
47
|
+
line_end = ',' if idx < len(items) -1 else ''
|
|
48
|
+
inner = f'{{ {json.dumps(val, ensure_ascii=ensure_ascii)[1:-1]} }}'
|
|
49
|
+
lines.append(f' "{key}": {inner}{line_end}')
|
|
50
|
+
lines.append('}')
|
|
51
|
+
json_str = '\n'.join(lines)
|
|
52
|
+
else: # minified to single line
|
|
53
|
+
json_str = json.dumps(data, separators=(',', ':'), ensure_ascii=ensure_ascii)
|
|
54
|
+
json_str += '\n'
|
|
55
|
+
getattr(file, 'atomic_write' if atomic else 'write')(file_path, json_str, encoding=encoding)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
def can_render_non_latin_scripts() -> bool: # e.g. ar, dv, zh
|
|
4
|
+
import json, os, subprocess
|
|
5
|
+
try:
|
|
6
|
+
result = subprocess.run(
|
|
7
|
+
['ucs-detect', '--quick', '--save-json', '-'], # to stdout vs. file
|
|
8
|
+
capture_output=True, text=True, timeout=0.1
|
|
9
|
+
)
|
|
10
|
+
return json.loads(result.stdout).get('wide', False)
|
|
11
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
12
|
+
return os.environ.get('WT_SESSION') is not None if sys.platform == 'win32' else True
|
|
13
|
+
|
|
14
|
+
def is_debug_mode() -> bool:
|
|
15
|
+
return any(arg in ('--debug', '-V') for arg in sys.argv[1:])
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from types import SimpleNamespace as sn
|
|
3
|
+
|
|
4
|
+
from . import data
|
|
5
|
+
|
|
6
|
+
def cli() -> sn:
|
|
7
|
+
from . import env, language, settings
|
|
8
|
+
cli = data.sns.from_dict(data.json.read(Path(__file__).parent.parent.parent / 'data/package_data.json'))
|
|
9
|
+
cli.msgs = language.get_msgs(cli,
|
|
10
|
+
language.generate_random_lang(excludes=['en']) if env.is_debug_mode() else language.get_sys_lang())
|
|
11
|
+
settings.load(cli)
|
|
12
|
+
return cli
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from types import SimpleNamespace as sn
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
def create_pkg_ver_url(cli: sn, version: Optional[str] = None) -> str:
|
|
5
|
+
version = version or cli.version
|
|
6
|
+
return f'{cli.urls.jsdelivr}@{cli.name}-{cli.version}/{cli.name}'
|
|
7
|
+
|
|
8
|
+
def create_commit_url(cli: sn, hash: str = 'latest') -> str:
|
|
9
|
+
return f'{cli.urls.jsdelivr}@{hash}/{cli.name}'
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import re, sys
|
|
3
|
+
from types import SimpleNamespace as sn
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from . import data, log
|
|
7
|
+
|
|
8
|
+
def format_code(lang_code: str) -> str: # to match locale dir name (e.g., 'zh-tw' -> 'zh_TW')
|
|
9
|
+
return re.sub(
|
|
10
|
+
r'([a-z]{2,8})[-_]([a-z]{2})',
|
|
11
|
+
lambda m: f'{m.group(1).lower()}_{m.group(2).upper()}',
|
|
12
|
+
lang_code, flags=re.IGNORECASE
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def generate_random_lang(includes: Optional[List[str]] = None,
|
|
16
|
+
excludes: Optional[List[str]] = None) -> str:
|
|
17
|
+
import random
|
|
18
|
+
|
|
19
|
+
if includes is None : includes = []
|
|
20
|
+
if excludes is None : excludes = []
|
|
21
|
+
|
|
22
|
+
def get_locales() -> List[str]:
|
|
23
|
+
|
|
24
|
+
# Read cache if found
|
|
25
|
+
cache_dir = Path(__file__).parent.parent / '_cache'
|
|
26
|
+
locale_cache = cache_dir / 'locales.json'
|
|
27
|
+
if locale_cache.exists():
|
|
28
|
+
try : return data.json.read(locale_cache)
|
|
29
|
+
except Exception : pass
|
|
30
|
+
|
|
31
|
+
# Discover pkg _locales
|
|
32
|
+
locales_dir = Path(__file__).parent.parent.parent / 'data/_locales'
|
|
33
|
+
if not locales_dir.exists() : return ['en']
|
|
34
|
+
locales = []
|
|
35
|
+
for entry in locales_dir.iterdir():
|
|
36
|
+
if entry.is_dir() and re.match(r'^\w{2}[-_]?\w{0,2}$', entry.name):
|
|
37
|
+
locales.append(entry.name)
|
|
38
|
+
|
|
39
|
+
# Cache result
|
|
40
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
data.json.write(locale_cache, locales)
|
|
42
|
+
|
|
43
|
+
return locales
|
|
44
|
+
|
|
45
|
+
locales = includes.copy() if includes else get_locales()
|
|
46
|
+
|
|
47
|
+
# Filter out excludes
|
|
48
|
+
exclude_set = set(excludes)
|
|
49
|
+
locales = [locale for locale in locales if locale not in exclude_set]
|
|
50
|
+
|
|
51
|
+
# Get random language
|
|
52
|
+
random_lang = random.choice(locales) if locales else 'en'
|
|
53
|
+
log.debug(f'Random language: {random_lang}')
|
|
54
|
+
|
|
55
|
+
return random_lang
|
|
56
|
+
|
|
57
|
+
def get_msgs(cli: sn, lang_code: str = 'en') -> sn:
|
|
58
|
+
from . import env, jsdelivr, url
|
|
59
|
+
|
|
60
|
+
lang_code = format_code(lang_code)
|
|
61
|
+
if getattr(get_msgs, 'cached', None) and lang_code == get_msgs.cached_lang:
|
|
62
|
+
return get_msgs.cached # don't re-fetch same msgs
|
|
63
|
+
|
|
64
|
+
msgs = data.json.flatten(data.json.read( # local ones
|
|
65
|
+
Path(__file__).parent.parent.parent / 'data/_locales/en/messages.json'))
|
|
66
|
+
|
|
67
|
+
if not lang_code.startswith('en'): # fetch non-English msgs from jsDelivr
|
|
68
|
+
import non_latin_locales
|
|
69
|
+
if lang_code.split('_')[0] in non_latin_locales and not env.can_render_non_latin_scripts(): # type: ignore
|
|
70
|
+
return sn(**msgs) # EN ones cuz non-Latin not supported
|
|
71
|
+
msg_base_url = f'{jsdelivr.create_commit_url(cli, cli.commit_hashes.locales)}' \
|
|
72
|
+
'/src/get_min_py/data/_locales'
|
|
73
|
+
msg_url = f'{msg_base_url}/{lang_code}/messages.json'
|
|
74
|
+
for attempt in range(3):
|
|
75
|
+
try: # fetch remote msgs
|
|
76
|
+
msgs = data.json.flatten(data.json.read(url.get(msg_url)))
|
|
77
|
+
break
|
|
78
|
+
except Exception: # retry up to 2X (region-stripped + EN)
|
|
79
|
+
if attempt == 2 : break
|
|
80
|
+
msg_url = ( re.sub(r'([^_]*)_[^/]*(/.*)', r'\1\2', msg_url) # strip region before retrying
|
|
81
|
+
if attempt == 0 and '-' in lang_code else f'{msg_base_url}/en/messages.json') # else use EN msgs
|
|
82
|
+
|
|
83
|
+
get_msgs.cached = msgs
|
|
84
|
+
get_msgs.cached_lang = lang_code
|
|
85
|
+
|
|
86
|
+
return sn(**msgs)
|
|
87
|
+
|
|
88
|
+
def get_sys_lang(cli: Optional[sn] = None) -> str:
|
|
89
|
+
import os, subprocess
|
|
90
|
+
try:
|
|
91
|
+
if sys.platform == 'win32':
|
|
92
|
+
return subprocess.run(
|
|
93
|
+
['powershell', '-Command', '(Get-Culture).TwoLetterISOLanguageName'],
|
|
94
|
+
capture_output=True, text=True, check=True
|
|
95
|
+
).stdout.strip()
|
|
96
|
+
else: # macOS/Linux
|
|
97
|
+
for env_lang_var in ['LANG', 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LC_NAME']:
|
|
98
|
+
sys_lang = os.environ.get(env_lang_var)
|
|
99
|
+
if sys_lang : return sys_lang.split('.')[0]
|
|
100
|
+
return 'en'
|
|
101
|
+
except Exception as err:
|
|
102
|
+
if cli : log.error(f'{cli.msgs.err_FAILED_TO_FETCH_SYS_LANG}: {err}')
|
|
103
|
+
return 'en'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os, sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from types import SimpleNamespace as sn
|
|
4
|
+
from typing import Optional
|
|
5
|
+
if sys.platform == 'win32' : import colorama ; colorama.init() # enable ANSI color support
|
|
6
|
+
|
|
7
|
+
from . import data as datalib, pkg
|
|
8
|
+
|
|
9
|
+
try : terminal_width = os.get_terminal_size()[0]
|
|
10
|
+
except OSError : terminal_width = 80
|
|
11
|
+
|
|
12
|
+
current_ver = datalib.json.read(Path(__file__).parent.parent.parent / 'data/package_data.json')['version']
|
|
13
|
+
next_maj_ver = pkg.get_next_maj_ver(current_ver)
|
|
14
|
+
_warned_keys = { 'cli': set(), 'config': set() }
|
|
15
|
+
|
|
16
|
+
colors = sn(
|
|
17
|
+
nc='\x1b[0m', # no color
|
|
18
|
+
br='\x1b[1;91m', # bright red
|
|
19
|
+
by='\x1b[1;33m', # bright yellow
|
|
20
|
+
bo='\x1b[38;5;214m', # bright orange
|
|
21
|
+
bg='\x1b[1;92m', # bright green
|
|
22
|
+
bc='\x1b[1;96m', # bright cyan
|
|
23
|
+
bw='\x1b[1;97m', # bright white
|
|
24
|
+
dg='\x1b[32m', # dark green
|
|
25
|
+
dy='\x1b[33m', # dark yellow
|
|
26
|
+
gry='\x1b[90m' # gray
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def data(msg: str, *args, no_newline: bool = False, **kwargs) -> None:
|
|
30
|
+
print(f'\n{colors.bw}{msg.format(*args, **kwargs)}{colors.nc}', end='' if no_newline else None)
|
|
31
|
+
def dim(msg: str, *args, no_newline: bool = False, **kwargs) -> None:
|
|
32
|
+
print(f'\n{colors.gry}{msg.format(*args, **kwargs)}{colors.nc}', end='' if no_newline else None)
|
|
33
|
+
def docs_url(cli: sn) -> None : tip(f'{cli.msgs.tip_FOR_MORE_HELP_VISIT}:\n{cli.urls.docs}')
|
|
34
|
+
def error(msg: str, *args, **kwargs) -> None : print(f'\n{colors.br}ERROR: {msg.format(*args, **kwargs)}{colors.nc}')
|
|
35
|
+
def help_cmd(cli: sn) -> None : info(f"{cli.msgs.log_TYPE} '{cli.cmds[0]} --help' {cli.msgs.log_FOR_AVAIL_OPTIONS}\n")
|
|
36
|
+
def info(msg: str, *args, end: str = '', **kwargs) -> None:
|
|
37
|
+
print(f'\n{colors.by}{msg.format(*args, **kwargs)}{colors.nc}', end=end)
|
|
38
|
+
def line_break() : print()
|
|
39
|
+
def overwrite_print(msg: str, *args, **kwargs) -> None:
|
|
40
|
+
sys.stdout.write('\r' + msg.format(*args, **kwargs).ljust(terminal_width)[:terminal_width])
|
|
41
|
+
def success(msg: str, *args, **kwargs) -> None : print(f'\n{colors.bg}{msg.format(*args, **kwargs)}{colors.nc}')
|
|
42
|
+
def tip(msg: str, *args, **kwargs) -> None : print(f'\n{colors.bc}TIP: {msg.format(*args, **kwargs)}{colors.nc}')
|
|
43
|
+
def version(cli: sn) -> None:
|
|
44
|
+
print(f'\n{colors.by}{cli.name}\n{colors.bw}{cli.msgs.log_VERSION.lower()}: {cli.version}{colors.nc}')
|
|
45
|
+
def warn(msg: str, *args, **kwargs) -> None : print(f'\n{colors.bo}WARNING: {msg.format(*args, **kwargs)}{colors.nc}')
|
|
46
|
+
|
|
47
|
+
def warn_legacy_option(cli: sn, flag: str, source: str) -> None:
|
|
48
|
+
from . import settings
|
|
49
|
+
warned_set = _warned_keys[source]
|
|
50
|
+
if flag in warned_set : return
|
|
51
|
+
canonical_key = settings.get_canonical_key(flag)
|
|
52
|
+
msg = f"{ cli.msgs.warn_CONFIG_FILE_KEY if source == 'config' else cli.msgs.warn_CLI_OPTION } {flag!r}"
|
|
53
|
+
if canonical_key:
|
|
54
|
+
canonical_ctrl = getattr(settings.controls, canonical_key, None)
|
|
55
|
+
if source == 'cli' and canonical_ctrl:
|
|
56
|
+
flags = [arg for arg in getattr(canonical_ctrl, 'args', []) if arg.startswith('-')]
|
|
57
|
+
if flag.startswith('-') and len(flag) == 2: # show short flag replacement
|
|
58
|
+
display_key = min(flags, key=len) if flags else f"--{canonical_key.replace('_', '-')}"
|
|
59
|
+
else: # show long flag replacement
|
|
60
|
+
long_flags = [flag for flag in flags if flag.startswith('--')]
|
|
61
|
+
display_key = long_flags[0] if long_flags else f"--{canonical_key.replace('_', '-')}"
|
|
62
|
+
else:
|
|
63
|
+
display_key = canonical_key
|
|
64
|
+
msg += f' {cli.msgs.warn_HAS_BEEN_REPLACED_BY} {display_key!r}'
|
|
65
|
+
else:
|
|
66
|
+
msg += f' {cli.msgs.warn_NO_LONGER_HAS_ANY_EFFECT}'
|
|
67
|
+
msg += f' {cli.msgs.warn_AND_WILL_BE_REMOVED} @ v{next_maj_ver}'
|
|
68
|
+
warn(msg) ; warned_set.add(flag)
|
|
69
|
+
|
|
70
|
+
def cmd_docs_url_exit(cli: sn, msg: str = '', cmd: str = 'help') -> None:
|
|
71
|
+
if msg : error(msg)
|
|
72
|
+
help_cmd(cli)
|
|
73
|
+
docs_url(cli)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
def debug(msg: str, cli: Optional[sn] = None, *args, **kwargs) -> None:
|
|
77
|
+
from . import env
|
|
78
|
+
if not env.is_debug_mode() : return
|
|
79
|
+
|
|
80
|
+
# Init --debug [target]
|
|
81
|
+
debug_key=None
|
|
82
|
+
debug_argidx = sys.argv.index('--debug') if '--debug' in sys.argv else sys.argv.index('-V')
|
|
83
|
+
if debug_argidx +1 < len(sys.argv) and not sys.argv[debug_argidx +1].startswith('-'):
|
|
84
|
+
debug_key = sys.argv[debug_argidx +1].replace('-', '_')
|
|
85
|
+
|
|
86
|
+
if cli: # init data line
|
|
87
|
+
if debug_key:
|
|
88
|
+
data_val = getattr(cli.config, debug_key, f'cli.config key {debug_key!r} {cli.msgs.warn_NOT_FOUND.lower()}')
|
|
89
|
+
else:
|
|
90
|
+
data_val = cli.config
|
|
91
|
+
msg += f'\n{colors.gry}{data_val}{colors.nc}'
|
|
92
|
+
|
|
93
|
+
if args: # use 'em
|
|
94
|
+
msg = msg.format(*args, **kwargs)
|
|
95
|
+
|
|
96
|
+
print(f'\n{colors.by}DEBUG: {msg}{colors.nc}')
|
|
97
|
+
|
|
98
|
+
def trunc(msg: str, end: str = '\n') -> None:
|
|
99
|
+
truncated_lines = [
|
|
100
|
+
line if len(line) < terminal_width else line[:terminal_width -4] + '...' for line in msg.splitlines()]
|
|
101
|
+
print('\n'.join(truncated_lines), end=end)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from types import SimpleNamespace as sn
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from . import log, string, url
|
|
6
|
+
|
|
7
|
+
controls = sn(
|
|
8
|
+
help=sn(
|
|
9
|
+
args=['-h', '--help'], action='help'),
|
|
10
|
+
version=sn(
|
|
11
|
+
args=['-v', '--version'], action='store_true', exit=True, handler=lambda cli: log.version(cli)),
|
|
12
|
+
docs=sn(
|
|
13
|
+
args=['--docs'], action='store_true', exit=True, handler=lambda cli: url.open(cli.urls.docs)),
|
|
14
|
+
debug=sn(
|
|
15
|
+
args=['-V', '--debug'], nargs='?', const=True, metavar='TARGET_KEY')
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def get_canonical_key(key: str) -> Optional[str]:
|
|
19
|
+
if key.startswith('-'): # convert CLI arg to full key name
|
|
20
|
+
for ctrl_key, ctrl in vars(controls).items():
|
|
21
|
+
if key in getattr(ctrl, 'args', []):
|
|
22
|
+
key = ctrl_key
|
|
23
|
+
break
|
|
24
|
+
legacy_key = key if key.startswith('legacy_') else f'legacy_{key}'
|
|
25
|
+
legacy_ctrl = getattr(controls, legacy_key, None)
|
|
26
|
+
stripped_key = string.removeprefix(key, 'legacy_')
|
|
27
|
+
return legacy_ctrl.replaced_by if legacy_ctrl and hasattr(legacy_ctrl, 'replaced_by') \
|
|
28
|
+
else stripped_key if hasattr(controls, stripped_key) \
|
|
29
|
+
else None
|
|
30
|
+
|
|
31
|
+
def is_neg_key(key: str) -> bool:
|
|
32
|
+
import re
|
|
33
|
+
return bool(re.match(r'^(?:no|disable|exclude)_', string.removeprefix(key, 'legacy_')))
|
|
34
|
+
|
|
35
|
+
def load(cli: sn) -> None:
|
|
36
|
+
import argparse
|
|
37
|
+
from . import data
|
|
38
|
+
|
|
39
|
+
cli.config = sn()
|
|
40
|
+
|
|
41
|
+
# Assign help tips from cli.msgs
|
|
42
|
+
for ctrl_key, ctrl in vars(controls).items():
|
|
43
|
+
if ctrl_key.startswith('legacy_') : continue
|
|
44
|
+
if not hasattr(ctrl, 'help') : ctrl.help = getattr(cli.msgs, f'help_{ctrl_key.upper()}')
|
|
45
|
+
|
|
46
|
+
# Parse CLI args
|
|
47
|
+
argp = argparse.ArgumentParser(description=cli.description, add_help=False)
|
|
48
|
+
argp.usage = cli.cmd_format
|
|
49
|
+
valid_argparse_kwargs = {
|
|
50
|
+
'action', 'choices', 'const', 'default', 'dest', 'help', 'metavar', 'nargs', 'required', 'type', 'version'}
|
|
51
|
+
for ctrl_key, ctrl in vars(controls).items(): # add args to argp
|
|
52
|
+
kwargs = ctrl.__dict__.copy()
|
|
53
|
+
args = kwargs.pop('args')
|
|
54
|
+
argparse_kwargs = { key:val for key,val in kwargs.items() if key in valid_argparse_kwargs }
|
|
55
|
+
if ctrl_key.startswith('legacy_'): # copy canonical attrs first
|
|
56
|
+
canonical_key = get_canonical_key(ctrl_key)
|
|
57
|
+
if canonical_key: # adjust argparse_kwargs
|
|
58
|
+
canonical_ctrl = getattr(controls, canonical_key)
|
|
59
|
+
argparse_kwargs.update({
|
|
60
|
+
key:val for key,val in canonical_ctrl.__dict__.items() if key in valid_argparse_kwargs })
|
|
61
|
+
argparse_kwargs['dest'] = canonical_key
|
|
62
|
+
if is_neg_key(ctrl_key) != is_neg_key(canonical_key):
|
|
63
|
+
argparse_kwargs['action'] = 'store_false' if argparse_kwargs['action'] == 'store_true' \
|
|
64
|
+
else 'store_true'
|
|
65
|
+
for arg in args:
|
|
66
|
+
if arg in sys.argv[1:]:
|
|
67
|
+
log.warn_legacy_option(cli, arg, source='cli')
|
|
68
|
+
break
|
|
69
|
+
argp.add_argument(*args, **argparse_kwargs)
|
|
70
|
+
parsed_args, unknown_args = argp.parse_known_args()
|
|
71
|
+
exempt_flags = [] # exempt valid dash-less args from validation
|
|
72
|
+
exempt_flags.extend(arg.lstrip('-') for ctrl_key, ctrl in vars(controls).items()
|
|
73
|
+
if getattr(ctrl, 'subcmd', False)
|
|
74
|
+
for arg in ctrl.args if len(arg) > 2) # skip short flags
|
|
75
|
+
if unknown_args:
|
|
76
|
+
unknown_flags = [arg for arg in unknown_args if arg.startswith('-')]
|
|
77
|
+
if unknown_flags:
|
|
78
|
+
log.cmd_docs_url_exit(cli, f"{cli.msgs.err_UNRECOGNIZED_ARGS}: {' '.join(unknown_flags)}", cmd='help')
|
|
79
|
+
for ctrl_key, ctrl in vars(controls).items(): # process subcmds
|
|
80
|
+
if getattr(ctrl, 'subcmd', False) \
|
|
81
|
+
and next(arg for arg in ctrl.args if arg.startswith('--'))[2:] in sys.argv[1:]:
|
|
82
|
+
setattr(parsed_args, ctrl_key, True)
|
|
83
|
+
applied_args = []
|
|
84
|
+
for arg in sys.argv[1:]:
|
|
85
|
+
if not arg.startswith('-') : continue
|
|
86
|
+
base_arg = arg.split('=')[0]
|
|
87
|
+
for ctrl_key, ctrl in vars(controls).items():
|
|
88
|
+
if base_arg in getattr(ctrl, 'args', []):
|
|
89
|
+
dest = get_canonical_key(ctrl_key) or ctrl_key if ctrl_key.startswith('legacy_') else ctrl_key
|
|
90
|
+
parsed_val = getattr(parsed_args, dest, None)
|
|
91
|
+
if parsed_val is not None:
|
|
92
|
+
setattr(cli.config, dest, parsed_val)
|
|
93
|
+
applied_args.append(arg)
|
|
94
|
+
break
|
|
95
|
+
log.debug(f'Args parsed! {log.colors.bg}{len(applied_args)} args applied {applied_args}', cli)
|
|
96
|
+
|
|
97
|
+
# Apply parsers/default_vals
|
|
98
|
+
for ctrl_key, ctrl in vars(controls).items():
|
|
99
|
+
if not hasattr(cli.config, ctrl_key):
|
|
100
|
+
setattr(cli.config, ctrl_key, ctrl.default_val if hasattr(ctrl, 'default_val') else None)
|
|
101
|
+
config_val = getattr(cli.config, ctrl_key)
|
|
102
|
+
if getattr(ctrl, 'parser', '') == 'csv':
|
|
103
|
+
setattr(cli.config, ctrl_key, data.csv.parse(config_val))
|
|
104
|
+
log.debug('All cli.config vals set!', cli)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import List, Optional, Tuple
|
|
2
|
+
|
|
3
|
+
def get(url: str, timeout: int = 5, encoding: str = 'utf-8') -> str:
|
|
4
|
+
from urllib.error import URLError
|
|
5
|
+
from urllib.request import urlopen
|
|
6
|
+
url = validate(url)
|
|
7
|
+
try:
|
|
8
|
+
with urlopen(url, timeout=timeout) as resp:
|
|
9
|
+
return resp.read().decode(encoding)
|
|
10
|
+
except URLError as err:
|
|
11
|
+
raise RuntimeError(f'Failed to fetch from {url}: {err}')
|
|
12
|
+
|
|
13
|
+
def open(url: str) -> None:
|
|
14
|
+
import webbrowser
|
|
15
|
+
url = validate(url)
|
|
16
|
+
try : webbrowser.open(url)
|
|
17
|
+
except Exception as err:
|
|
18
|
+
raise RuntimeError(f'Failed to open {url} in browser: {err}')
|
|
19
|
+
|
|
20
|
+
def validate(url: str, allowed_schemes: Tuple[str, ...] = ('http', 'https'),
|
|
21
|
+
allowed_domains: Optional[List[str]] = None) -> str:
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
parsed_url = urlparse(url)
|
|
24
|
+
|
|
25
|
+
if parsed_url.scheme not in allowed_schemes:
|
|
26
|
+
raise ValueError(f'URL scheme {parsed_url.scheme!r} not allowed. Allowed: {allowed_schemes}')
|
|
27
|
+
|
|
28
|
+
if allowed_domains:
|
|
29
|
+
input_domain = parsed_url.netloc.lower()
|
|
30
|
+
if not any(input_domain.endswith(allowed_domain.lower()) for allowed_domain in allowed_domains):
|
|
31
|
+
raise ValueError(f'URL domain {input_domain!r} not allowed. Allowed: {allowed_domains}')
|
|
32
|
+
|
|
33
|
+
return url
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"log_NO_REQ_FOUND": { "message": "No requirement found" },
|
|
3
|
+
"log_TYPE": { "message": "Type" },
|
|
4
|
+
"log_FOR_AVAIL_OPTIONS": { "message": "for available options" },
|
|
5
|
+
"log_VERSION": { "message": "Version" },
|
|
6
|
+
"tip_FOR_MORE_HELP_VISIT": { "message": "For more help, visit" },
|
|
7
|
+
"warn_NOT_FOUND": { "message": "Not found" },
|
|
8
|
+
"warn_CLI_OPTION": { "message": "CLI option" },
|
|
9
|
+
"warn_HAS_BEEN_REPLACED_BY": { "message": "has been replaced by" },
|
|
10
|
+
"warn_AND_WILL_BE_REMOVED": { "message": "and will be removed" },
|
|
11
|
+
"warn_NO_LONGER_HAS_ANY_EFFECT": { "message": "no longer has any effect" },
|
|
12
|
+
"err_UNRECOGNIZED_ARGS": { "message": "Unrecognized argument(s)" },
|
|
13
|
+
"help_HELP": { "message": "Show help screen" },
|
|
14
|
+
"help_VERSION": { "message": "Show version" },
|
|
15
|
+
"help_DOCS": { "message": "Open docs URL" },
|
|
16
|
+
"help_DEBUG": { "message": "Show debug logs" }
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "get-min-py",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Get the minimum Python version required for a PyPI package.",
|
|
5
|
+
"cmds": ["get-min-py", "check-min-py"],
|
|
6
|
+
"cmd_format": "get-min-py <pkg>[,pkg_b,pkg_c]",
|
|
7
|
+
"author": { "name": "Adam Lui", "email": "adam@kudoai.com", "url": "https://github.com/adamlui" },
|
|
8
|
+
"urls": {
|
|
9
|
+
"docs": "https://github.com/adamlui/python-utils/tree/main/get-min-py/docs",
|
|
10
|
+
"funding": {
|
|
11
|
+
"cashapp": "https://cash.app/$adamlui",
|
|
12
|
+
"github": "https://github.com/sponsors/adamlui",
|
|
13
|
+
"kofi": "https://ko-fi.com/adamlui",
|
|
14
|
+
"paypal": "https://paypal.me/adamlui"
|
|
15
|
+
},
|
|
16
|
+
"github": "https://github.com/adamlui/python-utils",
|
|
17
|
+
"jsdelivr": "https://cdn.jsdelivr.net/gh/adamlui/python-utils",
|
|
18
|
+
"pypi": "https://pypi.org/project/get-min-py/",
|
|
19
|
+
"pypistats": "https://pypistats.org/packages/get-min-py",
|
|
20
|
+
"support": "https://github.com/adamlui/python-utils/issues"
|
|
21
|
+
},
|
|
22
|
+
"commit_hashes": {
|
|
23
|
+
"data": "50a31e1",
|
|
24
|
+
"locales": "50a31e1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: get-min-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Get the minimum Python version required for a PyPI package.
|
|
5
|
+
Author-email: Adam Lui <adam@kudoai.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Changelog, https://github.com/adamlui/python-utils/releases/tag/get-min-py-1.0.0
|
|
8
|
+
Project-URL: Documentation, https://github.com/adamlui/python-utils/tree/main/get-min-py/docs
|
|
9
|
+
Project-URL: Funding, https://github.com/sponsors/adamlui
|
|
10
|
+
Project-URL: Homepage, https://github.com/adamlui/python-utils/tree/main/get-min-py/#readme
|
|
11
|
+
Project-URL: Issues, https://github.com/adamlui/python-utils/issues
|
|
12
|
+
Project-URL: PyPI Stats, https://pepy.tech/projects/get-min-py
|
|
13
|
+
Project-URL: Releases, https://github.com/adamlui/python-utils/releases
|
|
14
|
+
Project-URL: Repository, https://github.com/adamlui/python-utils
|
|
15
|
+
Keywords: api,auto-detect,classifiers,cli,compatibility,console,dev-tool,minimum-version,package-metadata,pypi,python-version,requires-python,version-check
|
|
16
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Intended Audience :: Information Technology
|
|
20
|
+
Classifier: Intended Audience :: System Administrators
|
|
21
|
+
Classifier: Natural Language :: English
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Programming Language :: Python
|
|
24
|
+
Classifier: Programming Language :: Python :: 3
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
34
|
+
Classifier: Topic :: Software Development
|
|
35
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
36
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
37
|
+
Classifier: Topic :: Utilities
|
|
38
|
+
Classifier: Topic :: Internet
|
|
39
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
40
|
+
Requires-Python: <4,>=3.8
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: docs/LICENSE.md
|
|
43
|
+
Requires-Dist: colorama~=0.4.6; platform_system == "Windows"
|
|
44
|
+
Requires-Dist: json5~=0.13.0
|
|
45
|
+
Requires-Dist: ucs-detect~=2.0.2
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: nox>=2026.2.9; extra == "dev"
|
|
48
|
+
Requires-Dist: remove-json-keys~=1.8.3; extra == "dev"
|
|
49
|
+
Requires-Dist: tomli~=2.4.0; extra == "dev"
|
|
50
|
+
Requires-Dist: tomli-w~=1.2.0; extra == "dev"
|
|
51
|
+
Requires-Dist: translate-messages~=1.8.3; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
<a id="top"></a>
|
|
55
|
+
|
|
56
|
+
# > get-min-py
|
|
57
|
+
|
|
58
|
+
<a href="https://github.com/adamlui/python-utils/releases/tag/get-min-py-1.0.0">
|
|
59
|
+
<img height=31 src="https://img.shields.io/badge/Latest_Build-1.0.0-32fcee.svg?logo=icinga&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
60
|
+
<a href="https://github.com/adamlui/python-utils/blob/main/get-min-py/docs/LICENSE.md">
|
|
61
|
+
<img height=31 src="https://img.shields.io/badge/License-MIT-f99b27.svg?logo=internetarchive&logoColor=white&labelColor=464646&style=for-the-badge"></a>
|
|
62
|
+
<a href="https://www.codefactor.io/repository/github/adamlui/python-utils">
|
|
63
|
+
<img height=31 src="https://img.shields.io/codefactor/grade/github/adamlui/python-utils?label=Code+Quality&logo=codefactor&logoColor=white&labelColor=464646&color=a0fc55&style=for-the-badge"></a>
|
|
64
|
+
<a href="https://sonarcloud.io/component_measures?metric=new_vulnerabilities&id=adamlui_python-utils">
|
|
65
|
+
<img height=31 src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fsonarcloud.io%2Fapi%2Fmeasures%2Fcomponent%3Fcomponent%3Dadamlui_python-utils%26metricKeys%3Dvulnerabilities&query=%24.component.measures.0.value&style=for-the-badge&logo=sonarcloud&logoColor=white&labelColor=464646&label=Vulnerabilities&color=fafc74"></a>
|
|
66
|
+
|
|
67
|
+
> ### _Get the minimum Python version required for a PyPI package._
|
|
68
|
+
|
|
69
|
+
Uses `python-requires`, or classifiers if not found.
|
|
70
|
+
|
|
71
|
+
## ⚡ Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install get-min-py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 💻 Command line usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
get-min-py <pkg>[,pkg_b,pkg_c] # or check-min-py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
|
|
85
|
+
<img src="https://cdn.jsdelivr.net/gh/adamlui/python-utils@97a74a8/get-min-py/assets/images/cli-output.png">
|
|
86
|
+
|
|
87
|
+
CLI options:
|
|
88
|
+
|
|
89
|
+
| Option | Description
|
|
90
|
+
| --------------------------------- | -----------------
|
|
91
|
+
| `-h`, `--help` | Show help screen
|
|
92
|
+
| `-v`, `--version` | Show version
|
|
93
|
+
| `-V`, `--debug [targetConfigKey]` | Show debug logs
|
|
94
|
+
| `--docs` | Open docs URL
|
|
95
|
+
|
|
96
|
+
## 🔌 API usage
|
|
97
|
+
|
|
98
|
+
```py
|
|
99
|
+
import get_min_py
|
|
100
|
+
|
|
101
|
+
# Single package
|
|
102
|
+
result = get_min_py('requests')
|
|
103
|
+
print(result) # '3.9'
|
|
104
|
+
|
|
105
|
+
# Multiple packages
|
|
106
|
+
results = get_min_py(['numpy', 'pandas', 'flask'])
|
|
107
|
+
print(results) # ['3.11', '3.11', '3.9']
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
_Note: Most type checkers will falsely warn_ `get_min_py` _is not a callable module because they are incapable of analyzing runtime behavior (where the module is replaced w/ a function for cleaner, direct access). You can safely suppress such warnings using_ `# type: ignore`.
|
|
111
|
+
|
|
112
|
+
## MIT License
|
|
113
|
+
|
|
114
|
+
Copyright © 2023–2026 [Adam Lui](https://github.com/adamlui).
|
|
115
|
+
|
|
116
|
+
#
|
|
117
|
+
|
|
118
|
+
<a href="#top">Back to top ↑</a>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
docs/LICENSE.md
|
|
3
|
+
docs/README.md
|
|
4
|
+
src/get_min_py/__init__.py
|
|
5
|
+
src/get_min_py/api.py
|
|
6
|
+
src/get_min_py.egg-info/PKG-INFO
|
|
7
|
+
src/get_min_py.egg-info/SOURCES.txt
|
|
8
|
+
src/get_min_py.egg-info/dependency_links.txt
|
|
9
|
+
src/get_min_py.egg-info/entry_points.txt
|
|
10
|
+
src/get_min_py.egg-info/requires.txt
|
|
11
|
+
src/get_min_py.egg-info/top_level.txt
|
|
12
|
+
src/get_min_py/cli/__init__.py
|
|
13
|
+
src/get_min_py/cli/__main__.py
|
|
14
|
+
src/get_min_py/cli/lib/env.py
|
|
15
|
+
src/get_min_py/cli/lib/init.py
|
|
16
|
+
src/get_min_py/cli/lib/jsdelivr.py
|
|
17
|
+
src/get_min_py/cli/lib/language.py
|
|
18
|
+
src/get_min_py/cli/lib/log.py
|
|
19
|
+
src/get_min_py/cli/lib/pkg.py
|
|
20
|
+
src/get_min_py/cli/lib/settings.py
|
|
21
|
+
src/get_min_py/cli/lib/string.py
|
|
22
|
+
src/get_min_py/cli/lib/url.py
|
|
23
|
+
src/get_min_py/cli/lib/data/__init__.py
|
|
24
|
+
src/get_min_py/cli/lib/data/csv.py
|
|
25
|
+
src/get_min_py/cli/lib/data/file.py
|
|
26
|
+
src/get_min_py/cli/lib/data/json.py
|
|
27
|
+
src/get_min_py/cli/lib/data/sns.py
|
|
28
|
+
src/get_min_py/data/package_data.json
|
|
29
|
+
src/get_min_py/data/_locales/en/messages.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
check-min-py = get_min_py.cli.__main__:main
|
|
3
|
+
check-min-ver = get_min_py.cli.__main__:main
|
|
4
|
+
checkminpy = get_min_py.cli.__main__:main
|
|
5
|
+
checkminver = get_min_py.cli.__main__:main
|
|
6
|
+
get-min-py = get_min_py.cli.__main__:main
|
|
7
|
+
get-min-ver = get_min_py.cli.__main__:main
|
|
8
|
+
getminpy = get_min_py.cli.__main__:main
|
|
9
|
+
getminver = get_min_py.cli.__main__:main
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
get_min_py
|