hassle.hassle_utilities

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

Increment the project.version field in pyproject.toml.

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:
40def get_minimum_py_version(src: str) -> str:
41    """Scan src with vermin and return minimum
42    python version."""
43    config = vermin.Config()
44    config.add_backport("typing")
45    config.add_backport("typing_extensions")
46    config.set_eval_annotations(True)
47    result = vermin.visit(src, config).minimum_versions()[1]
48    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:
51def get_project_code(project_path: Pathier) -> str:
52    """Read and return all code from project_path
53    as one string."""
54    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):
57def update_minimum_python_version(pyproject_path: Pathier):
58    """Use vermin to determine the minimum compatible
59    Python version and update the corresponding field
60    in pyproject.toml."""
61    project_code = get_project_code(pyproject_path.parent / "src")
62    meta = pyproject_path.loads()
63    minimum_version = get_minimum_py_version(project_code)
64    minimum_version = f">={minimum_version}"
65    meta["project"]["requires-python"] = minimum_version
66    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):
69def generate_docs(package_path: Pathier):
70    """Generate project documentation using pdoc."""
71    try:
72        (package_path / "docs").delete()
73    except Exception as e:
74        pass
75    os.system(
76        f"pdoc -o {package_path / 'docs'} {package_path / 'src' / package_path.stem}"
77    )

Generate project documentation using pdoc.

def update_dependencies( pyproject_path: pathier.pathier.Pathier, overwrite: bool, include_versions: bool = False):
 80def update_dependencies(
 81    pyproject_path: Pathier, overwrite: bool, include_versions: bool = False
 82):
 83    """Update dependencies list in pyproject.toml.
 84
 85    :param overwrite: If True, replace the dependencies in pyproject.toml
 86    with the results of packagelister.scan() .
 87    If False, packages returned by packagelister are appended to
 88    the current dependencies in pyproject.toml if they don't already
 89    exist in the field."""
 90    packages = packagelister.scan(pyproject_path.parent)
 91
 92    packages = [
 93        f"{package}~={packages[package]['version']}"
 94        if packages[package]["version"] and include_versions
 95        else f"{package}"
 96        for package in packages
 97        if package != pyproject_path.parent.stem
 98    ]
 99    packages = [
100        package.replace("speech_recognition", "speechRecognition")
101        for package in packages
102    ]
103    meta = pyproject_path.loads()
104    if overwrite:
105        meta["project"]["dependencies"] = packages
106    else:
107        for package in packages:
108            if "~" in package:
109                name = package.split("~")[0]
110            elif "=" in package:
111                name = package.split("=")[0]
112            else:
113                name = package
114            if all(
115                name not in dependency for dependency in meta["project"]["dependencies"]
116            ):
117                meta["project"]["dependencies"].append(package)
118    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):
121def update_changelog(pyproject_path: Pathier):
122    """Update project changelog."""
123    if hassle_config.config_exists():
124        config = hassle_config.load_config()
125    else:
126        hassle_config.warn()
127        print("Creating blank hassle_config.toml...")
128        config = hassle_config.load_config()
129    changelog_path = pyproject_path.parent / "CHANGELOG.md"
130    raw_changelog = [
131        line
132        for line in subprocess.run(
133            [
134                "auto-changelog",
135                "-p",
136                pyproject_path.parent,
137                "--tag-prefix",
138                config["git"]["tag_prefix"],
139                "--stdout",
140            ],
141            stdout=subprocess.PIPE,
142            text=True,
143        ).stdout.splitlines(True)
144        if not line.startswith(
145            (
146                "Full set of changes:",
147                f"* build {config['git']['tag_prefix']}",
148                "* update changelog",
149            )
150        )
151    ]
152    if changelog_path.exists():
153        previous_changelog = changelog_path.read_text().splitlines(True)[
154            2:
155        ]  # First two elements are "# Changelog\n" and "\n"
156        for line in previous_changelog:
157            # Release headers are prefixed with "## "
158            if line.startswith("## "):
159                new_changes = raw_changelog[: raw_changelog.index(line)]
160                break
161    else:
162        new_changes = raw_changelog
163        previous_changelog = []
164    # if new_changes == "# Changelog\n\n" then there were no new changes
165    if not "".join(new_changes) == "# Changelog\n\n":
166        changelog_path.write_text("".join(new_changes + previous_changelog))

Update project changelog.

def tag_version(package_path: pathier.pathier.Pathier):
169def tag_version(package_path: Pathier):
170    """Add a git tag corresponding to the version number in pyproject.toml."""
171    if hassle_config.config_exists():
172        tag_prefix = hassle_config.load_config()["git"]["tag_prefix"]
173    else:
174        hassle_config.warn()
175        tag_prefix = ""
176    version = (package_path / "pyproject.toml").loads()["project"]["version"]
177    os.chdir(package_path)
178    git = Git()
179    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):
182def format_files(path: Pathier):
183    """Use `Black` to format file(s)."""
184    try:
185        black.main([str(path)])
186    except SystemExit:
187        ...

Use Black to format file(s).

def on_primary_branch() -> bool:
190def on_primary_branch() -> bool:
191    """Returns `False` if repo is not currently on `main` or `master` branch."""
192    git = Git(True)
193    if git.current_branch not in ["main", "master"]:
194        return False
195    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:
198def latest_version_is_published(pyproject_path: Pathier) -> bool:
199    """Return `True` if the version number in `pyproject.toml` and the project page on `pypi.org` agree."""
200    data = pyproject_path.loads()
201    name = data["project"]["name"]
202    version = data["project"]["version"]
203    pypi_url = f"https://pypi.org/project/{name}"
204    response = requests.get(pypi_url)
205    if response.status_code != 200:
206        raise RuntimeError(f"{pypi_url} returned status code {response.status_code} :/")
207    soup = BeautifulSoup(response.text, "html.parser")
208    header = soup.find("h1", class_="package-header__name").text.strip()
209    pypi_version = header[header.rfind(" ") + 1 :]
210    return version == pypi_version

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