hassle.hassle_utilities API documentation

# hassle.hassle_utilities

View Source

      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): View Source

    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): View Source

    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: View Source

    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: View Source

    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): View Source

    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): View Source

    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): View Source

     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): View Source

    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): View Source

    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): View Source

    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: View Source

    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: View Source

    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.