Skip to content

Commit 130ece4

Browse files
committed
Add tests; separate cli and core logic
1 parent 2adedd7 commit 130ece4

File tree

14 files changed

+953
-85
lines changed

14 files changed

+953
-85
lines changed

.coverage

52 KB
Binary file not shown.

.github/actions/setup/action.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Setup environment
2+
description: |
3+
Sets up the development environment for this repository.
4+
5+
Notes:
6+
1. You have to first checkout the repository.
7+
2. To use the conda environment, you have to set `defaults.run.shell` to `bash -el {0}`. See [this page](https://github.com/marketplace/actions/setup-miniconda#important) for more details.
8+
9+
inputs:
10+
python-version:
11+
description: 'Python version'
12+
required: true
13+
14+
runs:
15+
using: composite
16+
steps:
17+
- name: Install Miniconda
18+
uses: conda-incubator/setup-miniconda@v3
19+
with:
20+
auto-update-conda: true
21+
python-version: ${{ inputs.python-version }}
22+
activate-environment: 'pyimorg-${{ inputs.python-version }}'
23+
- name: Install Poetry
24+
uses: snok/install-poetry@v1
25+
with:
26+
version: '1.4.0'

.github/workflows/pre-release.yaml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Verify pre-release
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- master
7+
push:
8+
branches:
9+
- master
10+
11+
permissions:
12+
contents: write
13+
14+
defaults:
15+
run:
16+
shell: bash -el {0}
17+
18+
jobs:
19+
lint:
20+
name: Lint code
21+
strategy:
22+
matrix:
23+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Checkout Git repository
27+
uses: actions/checkout@v4
28+
- name: Setup environment
29+
uses: ./.github/actions/setup
30+
with:
31+
python-version: ${{ matrix.python-version }}
32+
- name: Install repository
33+
run: |
34+
poetry lock --no-update
35+
poetry install --with dev
36+
- name: Check dependencies
37+
run: poetry run -- deptry .
38+
- name: Lint code
39+
run: poetry run -- ruff check .
40+
- name: Check type annotations
41+
run: poetry run -- pyright
42+
test:
43+
name: Test code
44+
strategy:
45+
matrix:
46+
os: [ubuntu-latest, windows-latest]
47+
python-version: ['3.8', '3.12']
48+
runs-on: ${{ matrix.os }}
49+
steps:
50+
- name: Checkout Git repository
51+
uses: actions/checkout@v4
52+
- uses: ./.github/actions/setup
53+
with:
54+
python-version: ${{ matrix.python-version }}
55+
- name: Install repository
56+
run: |
57+
poetry lock --no-update
58+
poetry install --with dev
59+
- name: Pytest with code coverage
60+
run: poetry run -- pytest -n auto
61+
- name: Archive code coverage results
62+
uses: actions/upload-artifact@v4
63+
with:
64+
name: coverage-${{ matrix.os }}-${{ matrix.python-version }}
65+
path: coverage/

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,7 @@ Python (↓)
5353
1. [Setup](#setup) the development environment.
5454
2. Run `poetry run deptry . && poetry run ruff check . && poetry run pyright .` to lint the code.
5555

56+
### Test
57+
58+
1. [Setup](#setup) the development environment.
59+
2. Run `poetry run -- pytest` to test the code and output the coverage report.

poetry.lock

Lines changed: 677 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pycm/__init__.py

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1 @@
1-
from __future__ import annotations
2-
3-
from collections import defaultdict
4-
from collections.abc import Collection
5-
from itertools import groupby
6-
import json
7-
import re
8-
from urllib.request import urlopen
9-
10-
import click
11-
import pandas as pd
12-
from pkg_resources import Requirement, parse_version
13-
14-
__all__ = ['cli']
15-
16-
# Dummy requirement that cannot be satisfied
17-
BAD_REQUIREMENT = Requirement.parse('python<0,>0')
18-
19-
def _get_requires_python_constraint(dist_data: dict[str, str]) -> Requirement | None:
20-
requires_python_data: str | None = dist_data.get('requires_python')
21-
if requires_python_data is None:
22-
return None
23-
24-
return Requirement.parse(f'python{requires_python_data}')
25-
26-
def _get_python_requirements(dist_data: dict[str, str]) -> Collection[Requirement]:
27-
requires_python_constraint = _get_requires_python_constraint(dist_data)
28-
29-
python_version_data: str | None = dist_data.get('python_version')
30-
if python_version_data is None or python_version_data == 'source':
31-
return [] if requires_python_constraint is None else [requires_python_constraint]
32-
33-
m = re.match(r'[a-z]{2}([0-9])([0-9]*)', python_version_data)
34-
if m is None:
35-
return [BAD_REQUIREMENT] if requires_python_constraint is None else [requires_python_constraint]
36-
37-
major, minor = m.groups()
38-
if minor:
39-
bdist_constraint = Requirement.parse(f'python=={major}.{minor}.*')
40-
else:
41-
bdist_constraint = Requirement.parse(f'python=={major}.*')
42-
43-
if requires_python_constraint is None:
44-
return [bdist_constraint]
45-
else:
46-
return [requires_python_constraint, bdist_constraint]
47-
48-
@click.command()
49-
@click.argument('package_name', type=str)
50-
@click.option('--python', 'python_versions', metavar='<VERSIONS>', type=str, required=True, help='A comma-separated list of Python versions to check against')
51-
def cli(package_name: str, python_versions: str) -> None:
52-
"""Show the versions of a PyPI package that are compatible with each Python version."""
53-
python_versions_lst = python_versions.split(',')
54-
output = defaultdict(lambda: defaultdict(lambda: ''))
55-
56-
data = json.load(urlopen(f'https://pypi.org/pypi/{package_name}/json'))
57-
for package_version, release_data in data['releases'].items():
58-
for dist_data in release_data:
59-
python_requirements = _get_python_requirements(dist_data)
60-
61-
for python_version in python_versions_lst:
62-
if all(python_version in r for r in python_requirements):
63-
output[python_version][package_version] = 'Y'
64-
65-
output_df = pd.DataFrame.from_dict(data=output, orient='index').fillna('')
66-
67-
# We only care about the latest patch version for each minor version
68-
output_columns = tuple(str(c) for c in output_df.columns)
69-
latest_patches = [
70-
max(columns, key=parse_version)
71-
for _, columns in groupby(output_columns, key=lambda s: parse_version(s).release[:2])
72-
]
73-
74-
formatted_output_df = output_df \
75-
.loc[sorted(output_df.index, key=parse_version), sorted(latest_patches, key=parse_version)] \
76-
.rename_axis(index='Python (↓)', columns=f'{package_name} (→)')
77-
78-
click.echo(formatted_output_df)
1+
from .pycm import *

pycm/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from . import cli
1+
from .cli import cli
22

33
cli()

pycm/cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
import click
4+
5+
from .pycm import pycm
6+
7+
__all__ = ['cli']
8+
9+
@click.command()
10+
@click.argument('package_name', type=str)
11+
@click.option('--python', 'python_versions', metavar='<VERSIONS>', type=str, required=True, help='A comma-separated list of Python versions to check against')
12+
def cli(package_name: str, python_versions: str) -> None:
13+
"""Show the versions of a PyPI package that are compatible with each Python version."""
14+
output_df = pycm(package_name, python_versions.split(','))
15+
click.echo(output_df)

pycm/pycm.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
from collections import defaultdict
4+
from collections.abc import Collection
5+
from itertools import groupby
6+
import json
7+
import re
8+
from urllib.request import urlopen
9+
10+
from packaging.requirements import Requirement
11+
from packaging.version import Version
12+
import pandas as pd
13+
14+
__all__ = ['pycm']
15+
16+
# Dummy requirement that cannot be satisfied
17+
BAD_REQUIREMENT = Requirement('python<0,>0')
18+
19+
def _get_requires_python_constraint(dist_data: dict[str, str]) -> Requirement | None:
20+
requires_python_data: str | None = dist_data.get('requires_python')
21+
if requires_python_data is None:
22+
return None
23+
24+
return Requirement(f'python{requires_python_data}')
25+
26+
def _get_python_requirements(dist_data: dict[str, str]) -> Collection[Requirement]:
27+
requires_python_constraint = _get_requires_python_constraint(dist_data)
28+
29+
python_version_data: str | None = dist_data.get('python_version')
30+
if python_version_data is None or python_version_data == 'source':
31+
return [] if requires_python_constraint is None else [requires_python_constraint]
32+
33+
m = re.match(r'[a-z]{2}([0-9])([0-9]*)', python_version_data)
34+
if m is None:
35+
return [BAD_REQUIREMENT] if requires_python_constraint is None else [requires_python_constraint]
36+
37+
major, minor = m.groups()
38+
if minor:
39+
bdist_constraint = Requirement(f'python=={major}.{minor}.*')
40+
else:
41+
bdist_constraint = Requirement(f'python=={major}.*')
42+
43+
if requires_python_constraint is None:
44+
return [bdist_constraint]
45+
else:
46+
return [requires_python_constraint, bdist_constraint]
47+
48+
def pycm(package_name: str, python_versions: list[str], *, groupby_patch: bool = True) -> pd.DataFrame:
49+
"""Show the versions of a PyPI package that are compatible with each Python version."""
50+
output = defaultdict(lambda: defaultdict(lambda: ''))
51+
52+
data = json.load(urlopen(f'https://pypi.org/pypi/{package_name}/json'))
53+
for package_version, release_data in data['releases'].items():
54+
for dist_data in release_data:
55+
python_requirements = _get_python_requirements(dist_data)
56+
57+
for python_version in python_versions:
58+
if all(python_version in r.specifier for r in python_requirements):
59+
output[python_version][package_version] = 'Y'
60+
61+
output_df = pd.DataFrame.from_dict(data=output, orient='index').fillna('')
62+
63+
64+
if groupby_patch:
65+
# We only care about the latest patch version for each minor version
66+
output_columns = tuple(str(c) for c in output_df.columns)
67+
output_columns = [
68+
max(columns, key=Version)
69+
for _, columns in groupby(output_columns, key=lambda s: Version(s).release[:2])
70+
]
71+
else:
72+
output_columns = output_df.columns
73+
74+
return output_df \
75+
.loc[sorted(output_df.index, key=Version), sorted(output_columns, key=Version)] \
76+
.rename_axis(index='Python (↓)', columns=f'{package_name} (→)')

pyproject.toml

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "pycm"
7-
version = "1.0.0"
7+
version = "1.0.1"
88
description = "Command-line tool for checking Python compatibility"
99
license = "MIT"
1010
authors = [
@@ -21,27 +21,37 @@ classifiers = [
2121
]
2222

2323
[tool.poetry.scripts]
24-
pycm = "pycm:cli"
24+
pycm = "pycm.cli:cli"
2525

2626
[tool.poetry.dependencies]
2727
python = "^3.8"
2828

2929
click = ">=8.1"
30-
pandas = ">=2.0"
30+
pandas = { version = ">=2.0", extras = ["html"] }
31+
packaging = ">=24.0"
3132

3233
[tool.poetry.group.dev]
3334
optional = true
3435

3536
[tool.poetry.group.dev.dependencies]
37+
pandas-stubs = ">=2.0"
38+
3639
deptry = ">=0.14"
3740
ruff = ">=0.3"
3841
pyright = ">=1.1.354"
3942

43+
pytest = ">=7.0"
44+
pytest-cov = ">=3.0"
45+
pytest-xdist = ">=3.5"
46+
47+
beautifulsoup4 = ">=4.12.3"
48+
requests = ">=2.31.0"
49+
4050
[tool.deptry]
41-
extend_exclude = ["__pycache__"]
51+
extend_exclude = ["\\.coverage/", "test", "__pycache__"]
4252

4353
[tool.deptry.per_rule_ignores]
44-
DEP001 = ["_typeshed", "pkg_resources"]
54+
DEP001 = ["_typeshed"]
4555

4656
[tool.ruff]
4757
line-length = 100
@@ -184,3 +194,26 @@ reportShadowedImports = "warning"
184194
reportUninitializedInstanceVariable = "warning"
185195
reportUnnecessaryTypeIgnoreComment = "information"
186196
# reportUnusedCallResult = "warning"
197+
198+
[tool.pytest.ini_options]
199+
addopts = "--cov=pycm --cov-report=term-missing --cov-report html:coverage --cov-branch --cov-context=test"
200+
testpaths = ["test"]
201+
202+
[tool.coverage.html]
203+
show_contexts = true
204+
205+
[tool.coverage.report]
206+
exclude_also = [
207+
"def __repr__",
208+
"if self.debug:",
209+
"if settings.DEBUG",
210+
"raise AssertionError",
211+
"raise NotImplementedError",
212+
"if 0:",
213+
"if __name__ == .__main__.:",
214+
"if TYPE_CHECKING:",
215+
"class .*\\bProtocol(\\[.*\\])?\\):",
216+
"@(abc\\.)?abstractmethod",
217+
"@(typing\\.)?overload",
218+
]
219+

0 commit comments

Comments
 (0)