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

Generate project documentation using pdoc.

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

Update project changelog.

def tag_version(package_path: pathier.pathier.Pathier):
188def tag_version(package_path: Pathier):
189    """Add a git tag corresponding to the version number in pyproject.toml."""
190    if hassle_config.config_exists():
191        tag_prefix = hassle_config.load_config()["git"]["tag_prefix"]
192    else:
193        hassle_config.warn()
194        tag_prefix = ""
195    version = (package_path / "pyproject.toml").loads()["project"]["version"]
196    os.chdir(package_path)
197    git = Git()
198    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):
201def format_files(path: Pathier):
202    """Use `Black` to format file(s)."""
203    try:
204        black.main([str(path)])
205    except SystemExit:
206        ...

Use Black to format file(s).

def on_primary_branch() -> bool:
209def on_primary_branch() -> bool:
210    """Returns `False` if repo is not currently on `main` or `master` branch."""
211    git = Git(True)
212    if git.current_branch not in ["main", "master"]:
213        return False
214    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:
217def latest_version_is_published(pyproject_path: Pathier) -> bool:
218    """Return `True` if the version number in `pyproject.toml` and the project page on `pypi.org` agree."""
219    data = pyproject_path.loads()
220    name = data["project"]["name"]
221    version = data["project"]["version"]
222    pypi_url = f"https://pypi.org/project/{name}"
223    response = requests.get(pypi_url)
224    if response.status_code != 200:
225        raise RuntimeError(f"{pypi_url} returned status code {response.status_code} :/")
226    soup = BeautifulSoup(response.text, "html.parser")
227    header = soup.find("h1", class_="package-header__name").text.strip()
228    pypi_version = header[header.rfind(" ") + 1 :]
229    return version == pypi_version

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