hassle.hassle_utilities

  1import os
  2import re
  3import subprocess
  4
  5import black
  6import packagelister
  7import requests
  8import vermin
  9from bs4 import BeautifulSoup
 10from gitbetter import Git
 11from pathier import Pathier
 12
 13from hassle import hassle_config
 14
 15root = Pathier(__file__).parent
 16
 17
 18def update_init_version(pyproject_path: Pathier):
 19    project = pyproject_path.loads()["project"]
 20    version = project["version"]
 21    name = project["name"]
 22    init_path: Pathier = pyproject_path.parent / "src" / name / "__init__.py"
 23    content = init_path.read_text()
 24    if "__version__" in content:
 25        content = re.sub(r"__version__.+", f'__version__ = "{version}"', content)
 26    else:
 27        content = content.strip("\n") + f'__version__ = "{version}"\n'
 28    init_path.write_text(content)
 29
 30
 31def increment_version(pyproject_path: Pathier, increment_type: str):
 32    """Increment the project.version field in pyproject.toml and `__version__` in `__init__.py`.
 33
 34    :param package_path: Path to the package/project directory.
 35
 36    :param increment_type: One from 'major', 'minor', or 'patch'."""
 37    meta = pyproject_path.loads()
 38    major, minor, patch = [int(num) for num in meta["project"]["version"].split(".")]
 39    if increment_type == "major":
 40        major += 1
 41        minor = 0
 42        patch = 0
 43    elif increment_type == "minor":
 44        minor += 1
 45        patch = 0
 46    elif increment_type == "patch":
 47        patch += 1
 48    incremented_version = ".".join(str(num) for num in [major, minor, patch])
 49    meta["project"]["version"] = incremented_version
 50    pyproject_path.dumps(meta)
 51    update_init_version(pyproject_path)
 52
 53
 54def get_minimum_py_version(src: str) -> str:
 55    """Scan src with vermin and return minimum
 56    python version."""
 57    config = vermin.Config()
 58    config.add_backport("typing")
 59    config.add_backport("typing_extensions")
 60    config.set_eval_annotations(True)
 61    result = vermin.visit(src, config).minimum_versions()[1]
 62    return f"{result[0]}.{result[1]}"
 63
 64
 65def get_project_code(project_path: Pathier) -> str:
 66    """Read and return all code from project_path
 67    as one string."""
 68    return "\n".join(file.read_text() for file in project_path.rglob("*.py"))
 69
 70
 71def update_minimum_python_version(pyproject_path: Pathier):
 72    """Use vermin to determine the minimum compatible
 73    Python version and update the corresponding field
 74    in pyproject.toml."""
 75    project_code = get_project_code(pyproject_path.parent / "src")
 76    meta = pyproject_path.loads()
 77    minimum_version = get_minimum_py_version(project_code)
 78    minimum_version = f">={minimum_version}"
 79    meta["project"]["requires-python"] = minimum_version
 80    pyproject_path.dumps(meta)
 81
 82
 83def generate_docs(package_path: Pathier):
 84    """Generate project documentation using pdoc."""
 85    try:
 86        (package_path / "docs").delete()
 87    except Exception as e:
 88        pass
 89    os.system(
 90        f"pdoc -o {package_path / 'docs'} {package_path / 'src' / package_path.stem}"
 91    )
 92
 93
 94def update_dependencies(
 95    pyproject_path: Pathier, overwrite: bool, include_versions: bool = False
 96):
 97    """Update dependencies list in pyproject.toml.
 98
 99    :param overwrite: If True, replace the dependencies in pyproject.toml
100    with the results of packagelister.scan() .
101    If False, packages returned by packagelister are appended to
102    the current dependencies in pyproject.toml if they don't already
103    exist in the field."""
104    packages = packagelister.scan(pyproject_path.parent)
105
106    packages = [
107        f"{package}~={packages[package]['version']}"
108        if packages[package]["version"] and include_versions
109        else f"{package}"
110        for package in packages
111        if package != pyproject_path.parent.stem
112    ]
113    packages = [
114        package.replace("speech_recognition", "speechRecognition")
115        for package in packages
116    ]
117    meta = pyproject_path.loads()
118    if overwrite:
119        meta["project"]["dependencies"] = packages
120    else:
121        for package in packages:
122            if "~" in package:
123                name = package.split("~")[0]
124            elif "=" in package:
125                name = package.split("=")[0]
126            else:
127                name = package
128            if all(
129                name not in dependency for dependency in meta["project"]["dependencies"]
130            ):
131                meta["project"]["dependencies"].append(package)
132    pyproject_path.dumps(meta)
133
134
135def update_changelog(pyproject_path: Pathier):
136    """Update project changelog."""
137    if hassle_config.config_exists():
138        config = hassle_config.load_config()
139    else:
140        hassle_config.warn()
141        print("Creating blank hassle_config.toml...")
142        config = hassle_config.load_config()
143    changelog_path = pyproject_path.parent / "CHANGELOG.md"
144    raw_changelog = [
145        line
146        for line in subprocess.run(
147            [
148                "auto-changelog",
149                "-p",
150                pyproject_path.parent,
151                "--tag-prefix",
152                config["git"]["tag_prefix"],
153                "--stdout",
154            ],
155            stdout=subprocess.PIPE,
156            text=True,
157        ).stdout.splitlines(True)
158        if not line.startswith(
159            (
160                "Full set of changes:",
161                f"* build {config['git']['tag_prefix']}",
162                "* update changelog",
163            )
164        )
165    ]
166    if changelog_path.exists():
167        previous_changelog = changelog_path.read_text().splitlines(True)[
168            2:
169        ]  # First two elements are "# Changelog\n" and "\n"
170        for line in previous_changelog:
171            # Release headers are prefixed with "## "
172            if line.startswith("## "):
173                new_changes = raw_changelog[: raw_changelog.index(line)]
174                break
175    else:
176        new_changes = raw_changelog
177        previous_changelog = []
178    # if new_changes == "# Changelog\n\n" then there were no new changes
179    if not "".join(new_changes) == "# Changelog\n\n":
180        changelog_path.write_text("".join(new_changes + previous_changelog))
181
182
183def tag_version(package_path: Pathier):
184    """Add a git tag corresponding to the version number in pyproject.toml."""
185    if hassle_config.config_exists():
186        tag_prefix = hassle_config.load_config()["git"]["tag_prefix"]
187    else:
188        hassle_config.warn()
189        tag_prefix = ""
190    version = (package_path / "pyproject.toml").loads()["project"]["version"]
191    os.chdir(package_path)
192    git = Git()
193    git.tag(f"{tag_prefix}{version}")
194
195
196def format_files(path: Pathier):
197    """Use `Black` to format file(s)."""
198    try:
199        black.main([str(path)])
200    except SystemExit:
201        ...
202
203
204def on_primary_branch() -> bool:
205    """Returns `False` if repo is not currently on `main` or `master` branch."""
206    git = Git(True)
207    if git.current_branch not in ["main", "master"]:
208        return False
209    return True
210
211
212def latest_version_is_published(pyproject_path: Pathier) -> bool:
213    """Return `True` if the version number in `pyproject.toml` and the project page on `pypi.org` agree."""
214    data = pyproject_path.loads()
215    name = data["project"]["name"]
216    version = data["project"]["version"]
217    pypi_url = f"https://pypi.org/project/{name}"
218    response = requests.get(pypi_url)
219    if response.status_code != 200:
220        raise RuntimeError(f"{pypi_url} returned status code {response.status_code} :/")
221    soup = BeautifulSoup(response.text, "html.parser")
222    header = soup.find("h1", class_="package-header__name").text.strip()
223    pypi_version = header[header.rfind(" ") + 1 :]
224    return version == pypi_version
def update_init_version(pyproject_path: pathier.pathier.Pathier):
19def update_init_version(pyproject_path: Pathier):
20    project = pyproject_path.loads()["project"]
21    version = project["version"]
22    name = project["name"]
23    init_path: Pathier = pyproject_path.parent / "src" / name / "__init__.py"
24    content = init_path.read_text()
25    if "__version__" in content:
26        content = re.sub(r"__version__.+", f'__version__ = "{version}"', content)
27    else:
28        content = content.strip("\n") + f'__version__ = "{version}"\n'
29    init_path.write_text(content)
def increment_version(pyproject_path: pathier.pathier.Pathier, increment_type: str):
32def increment_version(pyproject_path: Pathier, increment_type: str):
33    """Increment the project.version field in pyproject.toml and `__version__` in `__init__.py`.
34
35    :param package_path: Path to the package/project directory.
36
37    :param increment_type: One from 'major', 'minor', or 'patch'."""
38    meta = pyproject_path.loads()
39    major, minor, patch = [int(num) for num in meta["project"]["version"].split(".")]
40    if increment_type == "major":
41        major += 1
42        minor = 0
43        patch = 0
44    elif increment_type == "minor":
45        minor += 1
46        patch = 0
47    elif increment_type == "patch":
48        patch += 1
49    incremented_version = ".".join(str(num) for num in [major, minor, patch])
50    meta["project"]["version"] = incremented_version
51    pyproject_path.dumps(meta)
52    update_init_version(pyproject_path)

Increment the project.version field in pyproject.toml and __version__ in __init__.py.

Parameters
  • package_path: Path to the package/project directory.

  • increment_type: One from 'major', 'minor', or 'patch'.

def get_minimum_py_version(src: str) -> str:
55def get_minimum_py_version(src: str) -> str:
56    """Scan src with vermin and return minimum
57    python version."""
58    config = vermin.Config()
59    config.add_backport("typing")
60    config.add_backport("typing_extensions")
61    config.set_eval_annotations(True)
62    result = vermin.visit(src, config).minimum_versions()[1]
63    return f"{result[0]}.{result[1]}"

Scan src with vermin and return minimum python version.

def get_project_code(project_path: pathier.pathier.Pathier) -> str:
66def get_project_code(project_path: Pathier) -> str:
67    """Read and return all code from project_path
68    as one string."""
69    return "\n".join(file.read_text() for file in project_path.rglob("*.py"))

Read and return all code from project_path as one string.

def update_minimum_python_version(pyproject_path: pathier.pathier.Pathier):
72def update_minimum_python_version(pyproject_path: Pathier):
73    """Use vermin to determine the minimum compatible
74    Python version and update the corresponding field
75    in pyproject.toml."""
76    project_code = get_project_code(pyproject_path.parent / "src")
77    meta = pyproject_path.loads()
78    minimum_version = get_minimum_py_version(project_code)
79    minimum_version = f">={minimum_version}"
80    meta["project"]["requires-python"] = minimum_version
81    pyproject_path.dumps(meta)

Use vermin to determine the minimum compatible Python version and update the corresponding field in pyproject.toml.

def generate_docs(package_path: pathier.pathier.Pathier):
84def generate_docs(package_path: Pathier):
85    """Generate project documentation using pdoc."""
86    try:
87        (package_path / "docs").delete()
88    except Exception as e:
89        pass
90    os.system(
91        f"pdoc -o {package_path / 'docs'} {package_path / 'src' / package_path.stem}"
92    )

Generate project documentation using pdoc.

def update_dependencies( pyproject_path: pathier.pathier.Pathier, overwrite: bool, include_versions: bool = False):
 95def update_dependencies(
 96    pyproject_path: Pathier, overwrite: bool, include_versions: bool = False
 97):
 98    """Update dependencies list in pyproject.toml.
 99
100    :param overwrite: If True, replace the dependencies in pyproject.toml
101    with the results of packagelister.scan() .
102    If False, packages returned by packagelister are appended to
103    the current dependencies in pyproject.toml if they don't already
104    exist in the field."""
105    packages = packagelister.scan(pyproject_path.parent)
106
107    packages = [
108        f"{package}~={packages[package]['version']}"
109        if packages[package]["version"] and include_versions
110        else f"{package}"
111        for package in packages
112        if package != pyproject_path.parent.stem
113    ]
114    packages = [
115        package.replace("speech_recognition", "speechRecognition")
116        for package in packages
117    ]
118    meta = pyproject_path.loads()
119    if overwrite:
120        meta["project"]["dependencies"] = packages
121    else:
122        for package in packages:
123            if "~" in package:
124                name = package.split("~")[0]
125            elif "=" in package:
126                name = package.split("=")[0]
127            else:
128                name = package
129            if all(
130                name not in dependency for dependency in meta["project"]["dependencies"]
131            ):
132                meta["project"]["dependencies"].append(package)
133    pyproject_path.dumps(meta)

Update dependencies list in pyproject.toml.

Parameters
  • overwrite: If True, replace the dependencies in pyproject.toml with the results of packagelister.scan() . If False, packages returned by packagelister are appended to the current dependencies in pyproject.toml if they don't already exist in the field.
def update_changelog(pyproject_path: pathier.pathier.Pathier):
136def update_changelog(pyproject_path: Pathier):
137    """Update project changelog."""
138    if hassle_config.config_exists():
139        config = hassle_config.load_config()
140    else:
141        hassle_config.warn()
142        print("Creating blank hassle_config.toml...")
143        config = hassle_config.load_config()
144    changelog_path = pyproject_path.parent / "CHANGELOG.md"
145    raw_changelog = [
146        line
147        for line in subprocess.run(
148            [
149                "auto-changelog",
150                "-p",
151                pyproject_path.parent,
152                "--tag-prefix",
153                config["git"]["tag_prefix"],
154                "--stdout",
155            ],
156            stdout=subprocess.PIPE,
157            text=True,
158        ).stdout.splitlines(True)
159        if not line.startswith(
160            (
161                "Full set of changes:",
162                f"* build {config['git']['tag_prefix']}",
163                "* update changelog",
164            )
165        )
166    ]
167    if changelog_path.exists():
168        previous_changelog = changelog_path.read_text().splitlines(True)[
169            2:
170        ]  # First two elements are "# Changelog\n" and "\n"
171        for line in previous_changelog:
172            # Release headers are prefixed with "## "
173            if line.startswith("## "):
174                new_changes = raw_changelog[: raw_changelog.index(line)]
175                break
176    else:
177        new_changes = raw_changelog
178        previous_changelog = []
179    # if new_changes == "# Changelog\n\n" then there were no new changes
180    if not "".join(new_changes) == "# Changelog\n\n":
181        changelog_path.write_text("".join(new_changes + previous_changelog))

Update project changelog.

def tag_version(package_path: pathier.pathier.Pathier):
184def tag_version(package_path: Pathier):
185    """Add a git tag corresponding to the version number in pyproject.toml."""
186    if hassle_config.config_exists():
187        tag_prefix = hassle_config.load_config()["git"]["tag_prefix"]
188    else:
189        hassle_config.warn()
190        tag_prefix = ""
191    version = (package_path / "pyproject.toml").loads()["project"]["version"]
192    os.chdir(package_path)
193    git = Git()
194    git.tag(f"{tag_prefix}{version}")

Add a git tag corresponding to the version number in pyproject.toml.

def format_files(path: pathier.pathier.Pathier):
197def format_files(path: Pathier):
198    """Use `Black` to format file(s)."""
199    try:
200        black.main([str(path)])
201    except SystemExit:
202        ...

Use Black to format file(s).

def on_primary_branch() -> bool:
205def on_primary_branch() -> bool:
206    """Returns `False` if repo is not currently on `main` or `master` branch."""
207    git = Git(True)
208    if git.current_branch not in ["main", "master"]:
209        return False
210    return True

Returns False if repo is not currently on main or master branch.

def latest_version_is_published(pyproject_path: pathier.pathier.Pathier) -> bool:
213def latest_version_is_published(pyproject_path: Pathier) -> bool:
214    """Return `True` if the version number in `pyproject.toml` and the project page on `pypi.org` agree."""
215    data = pyproject_path.loads()
216    name = data["project"]["name"]
217    version = data["project"]["version"]
218    pypi_url = f"https://pypi.org/project/{name}"
219    response = requests.get(pypi_url)
220    if response.status_code != 200:
221        raise RuntimeError(f"{pypi_url} returned status code {response.status_code} :/")
222    soup = BeautifulSoup(response.text, "html.parser")
223    header = soup.find("h1", class_="package-header__name").text.strip()
224    pypi_version = header[header.rfind(" ") + 1 :]
225    return version == pypi_version

Return True if the version number in pyproject.toml and the project page on pypi.org agree.