hassle.models API documentation

# hassle.models

View Source

      1import subprocess
      2from dataclasses import asdict, dataclass, field
      3from datetime import datetime
      4from functools import cached_property
      5
      6import black
      7import dacite
      8import isort
      9import requests
     10from bs4 import BeautifulSoup, Tag
     11from packagelister import packagelister
     12from pathier import Pathier, Pathish
     13from typing_extensions import Self
     14
     15from hassle import utilities
     16
     17root = Pathier(__file__).parent
     18
     19
     20@dataclass
     21class Sdist:
     22    exclude: list[str]
     23
     24
     25@dataclass
     26class Targets:
     27    sdist: Sdist
     28
     29
     30@dataclass
     31class Build:
     32    targets: Targets
     33
     34
     35@dataclass
     36class BuildSystem:
     37    requires: list[str]
     38    build_backend: str
     39
     40
     41@dataclass
     42class Urls:
     43    Homepage: str = ""
     44    Documentation: str = ""
     45    Source_code: str = ""
     46
     47
     48@dataclass
     49class Author:
     50    name: str = ""
     51    email: str = ""
     52
     53
     54@dataclass
     55class Git:
     56    tag_prefix: str = ""
     57
     58
     59@dataclass
     60class IniOptions:
     61    addopts: list[str]
     62    pythonpath: str
     63
     64
     65@dataclass
     66class Pytest:
     67    ini_options: IniOptions
     68
     69
     70@dataclass
     71class Hatch:
     72    build: Build
     73
     74
     75@dataclass
     76class Tool:
     77    pytest: Pytest
     78    hatch: Hatch
     79
     80
     81@dataclass
     82class Project:
     83    name: str
     84    authors: list[Author] = field(default_factory=list)
     85    description: str = ""
     86    requires_python: str = ""
     87    version: str = ""
     88    dependencies: list[str] = field(default_factory=list)
     89    readme: str = ""
     90    keywords: list[str] = field(default_factory=list)
     91    classifiers: list[str] = field(default_factory=list)
     92    urls: Urls = field(default_factory=Urls)
     93    scripts: dict[str, str] = field(default_factory=dict)
     94
     95
     96@dataclass
     97class Pyproject:
     98    build_system: BuildSystem
     99    project: Project
    100    tool: Tool
    101
    102    @staticmethod
    103    def _swap_keys(data: dict) -> dict:
    104        """Swap between original toml key and valid Python variable."""
    105        if "build-system" in data:
    106            data = utilities.swap_keys(data, ("build-system", "build_system"))
    107            if "build-backend" in data["build_system"]:
    108                data["build_system"] = utilities.swap_keys(
    109                    data["build_system"], ("build-backend", "build_backend")
    110                )
    111        elif "build_system" in data:
    112            data = utilities.swap_keys(data, ("build-system", "build_system"))
    113            if "build_backend" in data["build-system"]:
    114                data["build-system"] = utilities.swap_keys(
    115                    data["build-system"], ("build-backend", "build_backend")
    116                )
    117
    118        if "project" in data and (
    119            "requires-python" in data["project"] or "requires_python"
    120        ):
    121            data["project"] = utilities.swap_keys(
    122                data["project"], ("requires-python", "requires_python")
    123            )
    124        if all(
    125            [
    126                "project" in data,
    127                "urls" in data["project"],
    128                (
    129                    "Source code" in data["project"]["urls"]
    130                    or "Source_code" in data["project"]["urls"]
    131                ),
    132            ]
    133        ):
    134            data["project"]["urls"] = utilities.swap_keys(
    135                data["project"]["urls"], ("Source code", "Source_code")
    136            )
    137
    138        return data
    139
    140    @classmethod
    141    def load(cls, path: Pathish = Pathier("pyproject.toml")) -> Self:
    142        """Return a `datamodel` object populated from `path`."""
    143        data = Pathier(path).loads()
    144        data = cls._swap_keys(data)
    145        return dacite.from_dict(cls, data)
    146
    147    def dump(self, path: Pathish = Pathier("pyproject.toml")):
    148        """Write the contents of this `datamodel` object to `path`."""
    149        data = asdict(self)
    150        data = self._swap_keys(data)
    151        Pathier(path).dumps(data)
    152
    153    @classmethod
    154    def from_template(cls) -> Self:
    155        """Return a `Pyproject` object using `templates/pyproject_template.toml`."""
    156        return cls.load(root / "templates" / "pyproject.toml")
    157
    158
    159@dataclass
    160class HassleConfig:
    161    authors: list[Author] = field(default_factory=list)
    162    project_urls: Urls = field(default_factory=Urls)
    163    git: Git = field(default_factory=Git)
    164
    165    @classmethod
    166    def load(
    167        cls, path: Pathish = Pathier(__file__).parent / "hassle_config.toml"
    168    ) -> Self:
    169        """Return a `datamodel` object populated from `path`."""
    170        path = Pathier(path)
    171        if not path.exists():
    172            raise FileNotFoundError(
    173                f"Could not find hassle config at {path}.\nRun hassle_config in a terminal to set it."
    174            )
    175        data = path.loads()
    176        data["project_urls"] = utilities.swap_keys(
    177            data["project_urls"], ("Source_code", "Source code")
    178        )
    179        return dacite.from_dict(cls, data)
    180
    181    def dump(self, path: Pathish = Pathier(__file__).parent / "hassle_config.toml"):
    182        """Write the contents of this `datamodel` object to `path`."""
    183        data = asdict(self)
    184        data["project_urls"] = utilities.swap_keys(
    185            data["project_urls"], ("Source_code", "Source code")
    186        )
    187        Pathier(path).dumps(data)
    188
    189    @staticmethod
    190    def warn():
    191        print("hassle_config.toml has not been set.")
    192        print("Run hassle_config to set it.")
    193        print("Run 'hassle config -h' for help.")
    194
    195    @staticmethod
    196    def exists(path: Pathish = Pathier(__file__).parent / "hassle_config.toml") -> bool:
    197        return Pathier(path).exists()
    198
    199    @classmethod
    200    def configure(
    201        cls,
    202        name: str | None = None,
    203        email: str | None = None,
    204        github_username: str | None = None,
    205        docs_url: str | None = None,
    206        tag_prefix: str | None = None,
    207        config_path: Pathish = Pathier(__file__).parent / "hassle_config.toml",
    208    ):
    209        """Create or edit `hassle_config.toml` from given params."""
    210        print(f"Manual edits can be made at {config_path}")
    211        if not cls.exists(config_path):
    212            config = cls()
    213        else:
    214            config = cls.load(config_path)
    215        # Add an author to config if a name or email is given.
    216        if name or email:
    217            config.authors.append(Author(name or "", email or ""))
    218        if github_username:
    219            homepage = f"https://github.com/{github_username}/$name"
    220            config.project_urls.Homepage = homepage
    221            config.project_urls.Source_code = f"{homepage}/tree/main/src/$name"
    222        if not config.project_urls.Documentation:
    223            if github_username and not docs_url:
    224                config.project_urls.Documentation = (
    225                    f"https://github.com/{github_username}/$name/tree/main/docs"
    226                )
    227            elif docs_url:
    228                config.project_urls.Documentation = docs_url
    229        if tag_prefix:
    230            config.git.tag_prefix = tag_prefix
    231        config.dump(config_path)
    232
    233
    234@dataclass
    235class HassleProject:
    236    pyproject: Pyproject
    237    projectdir: Pathier
    238    source_files: list[str]
    239    templatedir: Pathier = root / "templates"
    240
    241    @property
    242    def source_code(self) -> str:
    243        """Join and return all code from any `.py` files in `self.srcdir`.
    244
    245        Useful if a tool needs to scan all the source code for something."""
    246        return "\n".join(file.read_text() for file in self.srcdir.rglob("*.py"))
    247
    248    @cached_property
    249    def srcdir(self) -> Pathier:
    250        return self.projectdir / "src" / self.pyproject.project.name
    251
    252    @cached_property
    253    def changelog_path(self) -> Pathier:
    254        return self.projectdir / "CHANGELOG.md"
    255
    256    @cached_property
    257    def pyproject_path(self) -> Pathier:
    258        return self.projectdir / "pyproject.toml"
    259
    260    @cached_property
    261    def docsdir(self) -> Pathier:
    262        return self.projectdir / "docs"
    263
    264    @cached_property
    265    def testsdir(self) -> Pathier:
    266        return self.projectdir / "tests"
    267
    268    @cached_property
    269    def vsdir(self) -> Pathier:
    270        return self.projectdir / ".vscode"
    271
    272    @cached_property
    273    def distdir(self) -> Pathier:
    274        return self.projectdir / "dist"
    275
    276    @property
    277    def name(self) -> str:
    278        """This package's name."""
    279        return self.pyproject.project.name
    280
    281    @property
    282    def version(self) -> str:
    283        """This package's version."""
    284        return self.pyproject.project.version
    285
    286    @version.setter
    287    def version(self, new_version: str):
    288        self.pyproject.project.version = new_version
    289
    290    @classmethod
    291    def load(cls, projectdir: Pathish) -> Self:
    292        """Load a project given `projectdir`."""
    293        projectdir = Pathier(projectdir)
    294        pyproject = Pyproject.load(projectdir / "pyproject.toml")
    295        name = pyproject.project.name
    296        # Convert source files to path stems relative to projectdir/src/name
    297        # e.g `C:/python/projects/hassle/src/hassle/templates/pyproject.toml`
    298        # becomes `templates/pyproject.toml`
    299        source_files = [
    300            str(file.separate(name))
    301            for file in (projectdir / "src" / name).rglob("*")
    302            if file.is_file()
    303        ]
    304        return cls(pyproject, projectdir, source_files)
    305
    306    @classmethod
    307    def new(
    308        cls,
    309        targetdir: Pathier,
    310        name: str,
    311        description: str = "",
    312        dependencies: list[str] = [],
    313        keywords: list[str] = [],
    314        source_files: list[str] = [],
    315        add_script: bool = False,
    316        no_license: bool = False,
    317    ) -> Self:
    318        """Create and return a new hassle project."""
    319        pyproject = Pyproject.from_template()
    320        config = HassleConfig.load()
    321        pyproject.project.name = name
    322        pyproject.project.authors = config.authors
    323        pyproject.project.description = description
    324        pyproject.project.dependencies = dependencies
    325        pyproject.project.keywords = keywords
    326        pyproject.project.urls.Homepage = config.project_urls.Homepage.replace(
    327            "$name", name
    328        )
    329        pyproject.project.urls.Documentation = (
    330            config.project_urls.Documentation.replace("$name", name)
    331        )
    332        pyproject.project.urls.Source_code = config.project_urls.Source_code.replace(
    333            "$name", name
    334        )
    335        hassle = cls(pyproject, targetdir, source_files)
    336        if add_script:
    337            hassle.add_script(name, name)
    338        hassle.generate_files()
    339        if no_license:
    340            hassle.pyproject.project.classifiers.pop(1)
    341            (hassle.projectdir / "LICENSE.txt").delete()
    342        hassle.save()
    343        return hassle
    344
    345    def get_template(self, file_name: str) -> str:
    346        """Open are return the content of `{self.templatedir}/{file_name}`."""
    347        return (self.templatedir / file_name).read_text()
    348
    349    def save(self):
    350        """Dump `self.pyproject` to `{self.projectdir}/pyproject.toml`."""
    351        self.pyproject.dump(self.pyproject_path)
    352
    353    def format_source_files(self):
    354        """Use isort and black to format files"""
    355        for file in self.projectdir.rglob("*.py"):
    356            isort.file(file)
    357        try:
    358            black.main([str(self.projectdir)])
    359        except SystemExit as e:
    360            ...
    361        except Exception as e:
    362            raise e
    363
    364    def latest_version_is_published(self) -> bool:
    365        """Check if the current version of this project has been published to pypi.org."""
    366        pypi_url = f"https://pypi.org/project/{self.name}"
    367        response = requests.get(pypi_url)
    368        if response.status_code != 200:
    369            raise RuntimeError(
    370                f"{pypi_url} returned status code {response.status_code} :/"
    371            )
    372        soup = BeautifulSoup(response.text, "html.parser")
    373        header = soup.find("h1", class_="package-header__name")
    374        assert isinstance(header, Tag)
    375        text = header.text.strip()
    376        pypi_version = text[text.rfind(" ") + 1 :]
    377        return self.version == pypi_version
    378
    379    # ====================================================================================
    380    # Updaters ===========================================================================
    381    # ====================================================================================
    382    def add_script(self, name: str, file_stem: str, function: str = "main"):
    383        """Add a script to `pyproject.project.scripts` in the format `{name} = "{package_name}.{file_stem}:{function}"`"""
    384        self.pyproject.project.scripts[name] = f"{self.name}.{file_stem}:{function}"
    385
    386    def update_init_version(self):
    387        """Update the `__version__` in this projects `__init__.py` file
    388        to the current value of `self.pyproject.project.version`
    389        if it exists and has a `__version__` string.
    390
    391        If it doesn't have a `__version__` string, append one to it."""
    392        init_file = self.srcdir / "__init__.py"
    393        version = f'__version__ = "{self.version}"'
    394        if init_file.exists():
    395            content = init_file.read_text()
    396            if "__version__" in content:
    397                lines = content.splitlines()
    398                for i, line in enumerate(lines):
    399                    if line.startswith("__version__"):
    400                        lines[i] = version
    401                content = "\n".join(lines)
    402            else:
    403                content += f"\n{version}"
    404            init_file.write_text(content)
    405
    406    def bump_version(self, bump_type: str):
    407        """Bump the version of this project.
    408
    409        `bump_type` should be `major`, `minor`, or `patch`."""
    410        # bump pyproject version
    411        self.version = utilities.bump_version(self.version, bump_type)
    412        # bump `__version__` in __init__.py if the file exists and has a `__version__`.
    413        self.update_init_version()
    414
    415    def update_dependencies(
    416        self, overwrite_existing_packages: bool, include_versions: bool
    417    ):
    418        """Scan project for dependencies and update the corresponding field in the pyproject model.
    419
    420        If `overwrite_existing_packages` is `False`, this function will only add a package if it isn't already listed,
    421        but won't remove anything currently in the list.
    422        Use this option to preserve manually added dependencies."""
    423        project = packagelister.scan_dir(self.srcdir)
    424        version_conditional = ">=" if include_versions else None
    425        if overwrite_existing_packages:
    426            self.pyproject.project.dependencies = project.get_formatted_requirements(
    427                version_conditional
    428            )
    429        else:
    430            # Only add a package if it isn't already in the dependency list
    431            self.pyproject.project.dependencies.extend(
    432                [
    433                    package.get_formatted_requirement(version_conditional)
    434                    if version_conditional
    435                    else package.distribution_name
    436                    for package in project.requirements
    437                    if all(
    438                        package.distribution_name not in existing_dependency
    439                        for existing_dependency in self.pyproject.project.dependencies
    440                    )
    441                ]
    442            )
    443
    444    def _generate_changelog(self) -> list[str]:
    445        if HassleConfig.exists():
    446            tag_prefix = HassleConfig.load().git.tag_prefix
    447        else:
    448            HassleConfig.warn()
    449            print("Assuming no tag prefix.")
    450            tag_prefix = ""
    451        raw_changelog = [
    452            line
    453            for line in subprocess.run(
    454                [
    455                    "auto-changelog",
    456                    "-p",
    457                    self.projectdir,
    458                    "--tag-prefix",
    459                    tag_prefix,
    460                    "--stdout",
    461                ],
    462                stdout=subprocess.PIPE,
    463                text=True,
    464            ).stdout.splitlines(True)
    465            if not line.startswith(
    466                (
    467                    "Full set of changes:",
    468                    f"* build {tag_prefix}",
    469                    "* update changelog",
    470                )
    471            )
    472        ]
    473        return raw_changelog
    474
    475    def update_changelog(self):
    476        """Update `CHANGELOG.md` by invoking the `auto-changelog` module.
    477
    478        If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed."""
    479        raw_changelog = self._generate_changelog()
    480        # If there's no existing changelog, dump the generated one and get out of here.
    481        if not self.changelog_path.exists():
    482            self.changelog_path.join(raw_changelog)
    483            return
    484
    485        # Don't want to overwrite previously existing manual changes/edits
    486        existing_changelog = self.changelog_path.read_text().splitlines(True)[
    487            2:
    488        ]  # First two elements are "# Changelog\n" and "\n"
    489        new_changes = raw_changelog
    490        for line in existing_changelog:
    491            # Release headers are prefixed with "## "
    492            if line.startswith("## "):
    493                new_changes = raw_changelog[: raw_changelog.index(line)]
    494                break
    495        changes = "".join(new_changes)
    496        # "#### OTHERS" gets added to the changelog even when there's nothing for that category,
    497        # so we'll get rid of it if that's the case
    498        others = "#### Others"
    499        if changes.strip("\n").endswith(others):
    500            changes = changes.strip("\n").replace(others, "\n\n")
    501        # If changes == "# Changelog\n\n" then there weren't actually any new changes
    502        if not changes == "# Changelog\n\n":
    503            self.changelog_path.write_text(changes + "".join(existing_changelog))
    504
    505    # ====================================================================================
    506    # File/Project creation ==============================================================
    507    # ====================================================================================
    508
    509    def create_source_files(self):
    510        """Generate source files in `self.srcdir`."""
    511        for file in self.source_files:
    512            (self.srcdir / file).touch()
    513        init = self.srcdir / "__init__.py"
    514        if init.exists():
    515            init.append(f'__version__ = "{self.version}"')
    516
    517    def create_readme(self):
    518        readme = self.get_template("README.md")
    519        readme = readme.replace("$name", self.name)
    520        readme = readme.replace("$description", self.pyproject.project.description)
    521        (self.projectdir / "README.md").write_text(readme)
    522
    523    def create_license(self):
    524        license_ = self.get_template("license.txt")
    525        license_ = license_.replace("$year", str(datetime.now().year))
    526        (self.projectdir / "LICENSE.txt").write_text(license_)
    527
    528    def create_gitignore(self):
    529        (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")
    530
    531    def create_vscode_settings(self):
    532        self.vsdir.mkdir()
    533        (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json")
    534
    535    def create_tests(self):
    536        (self.testsdir / f"test_{self.name}.py").touch()
    537
    538    def generate_files(self):
    539        """Create all the necessary files.
    540
    541        Note: This will overwrite any existing files."""
    542        self.projectdir.mkdir()
    543        for func in dir(self):
    544            if func.startswith("create_"):
    545                getattr(self, func)()
    546        self.pyproject.dump(self.pyproject_path)
    547
    548    def generate_docs(self):
    549        """Generate docs by invoking `pdoc`"""
    550        self.docsdir.delete()
    551        subprocess.run(["pdoc", "-o", self.docsdir, self.srcdir])

@dataclass
class Sdist: View Source

    21@dataclass
    22class Sdist:
    23    exclude: list[str]

Sdist(exclude: list[str])
@dataclass
class Targets: View Source

    26@dataclass
    27class Targets:
    28    sdist: Sdist

Targets(sdist: hassle.models.Sdist)
@dataclass
class Build: View Source

    31@dataclass
    32class Build:
    33    targets: Targets

Build(targets: hassle.models.Targets)
@dataclass
class BuildSystem: View Source

    36@dataclass
    37class BuildSystem:
    38    requires: list[str]
    39    build_backend: str

BuildSystem(requires: list[str], build_backend: str)
@dataclass
class Urls: View Source

    42@dataclass
    43class Urls:
    44    Homepage: str = ""
    45    Documentation: str = ""
    46    Source_code: str = ""

Urls(Homepage: str = '', Documentation: str = '', Source_code: str = '')
@dataclass
class Author: View Source

    49@dataclass
    50class Author:
    51    name: str = ""
    52    email: str = ""

Author(name: str = '', email: str = '')
@dataclass
class Git: View Source

    55@dataclass
    56class Git:
    57    tag_prefix: str = ""

Git(tag_prefix: str = '')
@dataclass
class IniOptions: View Source

    60@dataclass
    61class IniOptions:
    62    addopts: list[str]
    63    pythonpath: str

IniOptions(addopts: list[str], pythonpath: str)
@dataclass
class Pytest: View Source

    66@dataclass
    67class Pytest:
    68    ini_options: IniOptions

Pytest(ini_options: hassle.models.IniOptions)
@dataclass
class Hatch: View Source

    71@dataclass
    72class Hatch:
    73    build: Build

Hatch(build: hassle.models.Build)
@dataclass
class Tool: View Source

    76@dataclass
    77class Tool:
    78    pytest: Pytest
    79    hatch: Hatch

Tool(pytest: hassle.models.Pytest, hatch: hassle.models.Hatch)
@dataclass
class Project: View Source

    82@dataclass
    83class Project:
    84    name: str
    85    authors: list[Author] = field(default_factory=list)
    86    description: str = ""
    87    requires_python: str = ""
    88    version: str = ""
    89    dependencies: list[str] = field(default_factory=list)
    90    readme: str = ""
    91    keywords: list[str] = field(default_factory=list)
    92    classifiers: list[str] = field(default_factory=list)
    93    urls: Urls = field(default_factory=Urls)
    94    scripts: dict[str, str] = field(default_factory=dict)

Project( name: str, authors: list[hassle.models.Author] = <factory>, description: str = '', requires_python: str = '', version: str = '', dependencies: list[str] = <factory>, readme: str = '', keywords: list[str] = <factory>, classifiers: list[str] = <factory>, urls: hassle.models.Urls = <factory>, scripts: dict[str, str] = <factory>)
@dataclass
class Pyproject: View Source

     97@dataclass
     98class Pyproject:
     99    build_system: BuildSystem
    100    project: Project
    101    tool: Tool
    102
    103    @staticmethod
    104    def _swap_keys(data: dict) -> dict:
    105        """Swap between original toml key and valid Python variable."""
    106        if "build-system" in data:
    107            data = utilities.swap_keys(data, ("build-system", "build_system"))
    108            if "build-backend" in data["build_system"]:
    109                data["build_system"] = utilities.swap_keys(
    110                    data["build_system"], ("build-backend", "build_backend")
    111                )
    112        elif "build_system" in data:
    113            data = utilities.swap_keys(data, ("build-system", "build_system"))
    114            if "build_backend" in data["build-system"]:
    115                data["build-system"] = utilities.swap_keys(
    116                    data["build-system"], ("build-backend", "build_backend")
    117                )
    118
    119        if "project" in data and (
    120            "requires-python" in data["project"] or "requires_python"
    121        ):
    122            data["project"] = utilities.swap_keys(
    123                data["project"], ("requires-python", "requires_python")
    124            )
    125        if all(
    126            [
    127                "project" in data,
    128                "urls" in data["project"],
    129                (
    130                    "Source code" in data["project"]["urls"]
    131                    or "Source_code" in data["project"]["urls"]
    132                ),
    133            ]
    134        ):
    135            data["project"]["urls"] = utilities.swap_keys(
    136                data["project"]["urls"], ("Source code", "Source_code")
    137            )
    138
    139        return data
    140
    141    @classmethod
    142    def load(cls, path: Pathish = Pathier("pyproject.toml")) -> Self:
    143        """Return a `datamodel` object populated from `path`."""
    144        data = Pathier(path).loads()
    145        data = cls._swap_keys(data)
    146        return dacite.from_dict(cls, data)
    147
    148    def dump(self, path: Pathish = Pathier("pyproject.toml")):
    149        """Write the contents of this `datamodel` object to `path`."""
    150        data = asdict(self)
    151        data = self._swap_keys(data)
    152        Pathier(path).dumps(data)
    153
    154    @classmethod
    155    def from_template(cls) -> Self:
    156        """Return a `Pyproject` object using `templates/pyproject_template.toml`."""
    157        return cls.load(root / "templates" / "pyproject.toml")

Pyproject( build_system: hassle.models.BuildSystem, project: hassle.models.Project, tool: hassle.models.Tool)
@classmethod
def load( cls, path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('pyproject.toml')) -> Self: View Source

    141    @classmethod
    142    def load(cls, path: Pathish = Pathier("pyproject.toml")) -> Self:
    143        """Return a `datamodel` object populated from `path`."""
    144        data = Pathier(path).loads()
    145        data = cls._swap_keys(data)
    146        return dacite.from_dict(cls, data)

Return a `datamodel` object populated from `path`.

def dump( self, path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('pyproject.toml')): View Source

    148    def dump(self, path: Pathish = Pathier("pyproject.toml")):
    149        """Write the contents of this `datamodel` object to `path`."""
    150        data = asdict(self)
    151        data = self._swap_keys(data)
    152        Pathier(path).dumps(data)

Write the contents of this `datamodel` object to `path`.

@classmethod
def from_template(cls) -> Self: View Source

    154    @classmethod
    155    def from_template(cls) -> Self:
    156        """Return a `Pyproject` object using `templates/pyproject_template.toml`."""
    157        return cls.load(root / "templates" / "pyproject.toml")

Return a `Pyproject` object using `templates/pyproject_template.toml`.

@dataclass
class HassleConfig: View Source

    160@dataclass
    161class HassleConfig:
    162    authors: list[Author] = field(default_factory=list)
    163    project_urls: Urls = field(default_factory=Urls)
    164    git: Git = field(default_factory=Git)
    165
    166    @classmethod
    167    def load(
    168        cls, path: Pathish = Pathier(__file__).parent / "hassle_config.toml"
    169    ) -> Self:
    170        """Return a `datamodel` object populated from `path`."""
    171        path = Pathier(path)
    172        if not path.exists():
    173            raise FileNotFoundError(
    174                f"Could not find hassle config at {path}.\nRun hassle_config in a terminal to set it."
    175            )
    176        data = path.loads()
    177        data["project_urls"] = utilities.swap_keys(
    178            data["project_urls"], ("Source_code", "Source code")
    179        )
    180        return dacite.from_dict(cls, data)
    181
    182    def dump(self, path: Pathish = Pathier(__file__).parent / "hassle_config.toml"):
    183        """Write the contents of this `datamodel` object to `path`."""
    184        data = asdict(self)
    185        data["project_urls"] = utilities.swap_keys(
    186            data["project_urls"], ("Source_code", "Source code")
    187        )
    188        Pathier(path).dumps(data)
    189
    190    @staticmethod
    191    def warn():
    192        print("hassle_config.toml has not been set.")
    193        print("Run hassle_config to set it.")
    194        print("Run 'hassle config -h' for help.")
    195
    196    @staticmethod
    197    def exists(path: Pathish = Pathier(__file__).parent / "hassle_config.toml") -> bool:
    198        return Pathier(path).exists()
    199
    200    @classmethod
    201    def configure(
    202        cls,
    203        name: str | None = None,
    204        email: str | None = None,
    205        github_username: str | None = None,
    206        docs_url: str | None = None,
    207        tag_prefix: str | None = None,
    208        config_path: Pathish = Pathier(__file__).parent / "hassle_config.toml",
    209    ):
    210        """Create or edit `hassle_config.toml` from given params."""
    211        print(f"Manual edits can be made at {config_path}")
    212        if not cls.exists(config_path):
    213            config = cls()
    214        else:
    215            config = cls.load(config_path)
    216        # Add an author to config if a name or email is given.
    217        if name or email:
    218            config.authors.append(Author(name or "", email or ""))
    219        if github_username:
    220            homepage = f"https://github.com/{github_username}/$name"
    221            config.project_urls.Homepage = homepage
    222            config.project_urls.Source_code = f"{homepage}/tree/main/src/$name"
    223        if not config.project_urls.Documentation:
    224            if github_username and not docs_url:
    225                config.project_urls.Documentation = (
    226                    f"https://github.com/{github_username}/$name/tree/main/docs"
    227                )
    228            elif docs_url:
    229                config.project_urls.Documentation = docs_url
    230        if tag_prefix:
    231            config.git.tag_prefix = tag_prefix
    232        config.dump(config_path)

HassleConfig( authors: list[hassle.models.Author] = <factory>, project_urls: hassle.models.Urls = <factory>, git: hassle.models.Git = <factory>)
@classmethod
def load( cls, path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('E:/1vsCode/python/hassle/src/hassle/hassle_config.toml')) -> Self: View Source

    166    @classmethod
    167    def load(
    168        cls, path: Pathish = Pathier(__file__).parent / "hassle_config.toml"
    169    ) -> Self:
    170        """Return a `datamodel` object populated from `path`."""
    171        path = Pathier(path)
    172        if not path.exists():
    173            raise FileNotFoundError(
    174                f"Could not find hassle config at {path}.\nRun hassle_config in a terminal to set it."
    175            )
    176        data = path.loads()
    177        data["project_urls"] = utilities.swap_keys(
    178            data["project_urls"], ("Source_code", "Source code")
    179        )
    180        return dacite.from_dict(cls, data)

Return a `datamodel` object populated from `path`.

def dump( self, path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('E:/1vsCode/python/hassle/src/hassle/hassle_config.toml')): View Source

    182    def dump(self, path: Pathish = Pathier(__file__).parent / "hassle_config.toml"):
    183        """Write the contents of this `datamodel` object to `path`."""
    184        data = asdict(self)
    185        data["project_urls"] = utilities.swap_keys(
    186            data["project_urls"], ("Source_code", "Source code")
    187        )
    188        Pathier(path).dumps(data)

Write the contents of this `datamodel` object to `path`.

@staticmethod
def warn(): View Source

    190    @staticmethod
    191    def warn():
    192        print("hassle_config.toml has not been set.")
    193        print("Run hassle_config to set it.")
    194        print("Run 'hassle config -h' for help.")

@staticmethod
def exists( path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('E:/1vsCode/python/hassle/src/hassle/hassle_config.toml')) -> bool: View Source

    196    @staticmethod
    197    def exists(path: Pathish = Pathier(__file__).parent / "hassle_config.toml") -> bool:
    198        return Pathier(path).exists()

@classmethod
def configure( cls, name: str | None = None, email: str | None = None, github_username: str | None = None, docs_url: str | None = None, tag_prefix: str | None = None, config_path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('E:/1vsCode/python/hassle/src/hassle/hassle_config.toml')): View Source

    200    @classmethod
    201    def configure(
    202        cls,
    203        name: str | None = None,
    204        email: str | None = None,
    205        github_username: str | None = None,
    206        docs_url: str | None = None,
    207        tag_prefix: str | None = None,
    208        config_path: Pathish = Pathier(__file__).parent / "hassle_config.toml",
    209    ):
    210        """Create or edit `hassle_config.toml` from given params."""
    211        print(f"Manual edits can be made at {config_path}")
    212        if not cls.exists(config_path):
    213            config = cls()
    214        else:
    215            config = cls.load(config_path)
    216        # Add an author to config if a name or email is given.
    217        if name or email:
    218            config.authors.append(Author(name or "", email or ""))
    219        if github_username:
    220            homepage = f"https://github.com/{github_username}/$name"
    221            config.project_urls.Homepage = homepage
    222            config.project_urls.Source_code = f"{homepage}/tree/main/src/$name"
    223        if not config.project_urls.Documentation:
    224            if github_username and not docs_url:
    225                config.project_urls.Documentation = (
    226                    f"https://github.com/{github_username}/$name/tree/main/docs"
    227                )
    228            elif docs_url:
    229                config.project_urls.Documentation = docs_url
    230        if tag_prefix:
    231            config.git.tag_prefix = tag_prefix
    232        config.dump(config_path)

Create or edit `hassle_config.toml` from given params.

@dataclass
class HassleProject: View Source

    235@dataclass
    236class HassleProject:
    237    pyproject: Pyproject
    238    projectdir: Pathier
    239    source_files: list[str]
    240    templatedir: Pathier = root / "templates"
    241
    242    @property
    243    def source_code(self) -> str:
    244        """Join and return all code from any `.py` files in `self.srcdir`.
    245
    246        Useful if a tool needs to scan all the source code for something."""
    247        return "\n".join(file.read_text() for file in self.srcdir.rglob("*.py"))
    248
    249    @cached_property
    250    def srcdir(self) -> Pathier:
    251        return self.projectdir / "src" / self.pyproject.project.name
    252
    253    @cached_property
    254    def changelog_path(self) -> Pathier:
    255        return self.projectdir / "CHANGELOG.md"
    256
    257    @cached_property
    258    def pyproject_path(self) -> Pathier:
    259        return self.projectdir / "pyproject.toml"
    260
    261    @cached_property
    262    def docsdir(self) -> Pathier:
    263        return self.projectdir / "docs"
    264
    265    @cached_property
    266    def testsdir(self) -> Pathier:
    267        return self.projectdir / "tests"
    268
    269    @cached_property
    270    def vsdir(self) -> Pathier:
    271        return self.projectdir / ".vscode"
    272
    273    @cached_property
    274    def distdir(self) -> Pathier:
    275        return self.projectdir / "dist"
    276
    277    @property
    278    def name(self) -> str:
    279        """This package's name."""
    280        return self.pyproject.project.name
    281
    282    @property
    283    def version(self) -> str:
    284        """This package's version."""
    285        return self.pyproject.project.version
    286
    287    @version.setter
    288    def version(self, new_version: str):
    289        self.pyproject.project.version = new_version
    290
    291    @classmethod
    292    def load(cls, projectdir: Pathish) -> Self:
    293        """Load a project given `projectdir`."""
    294        projectdir = Pathier(projectdir)
    295        pyproject = Pyproject.load(projectdir / "pyproject.toml")
    296        name = pyproject.project.name
    297        # Convert source files to path stems relative to projectdir/src/name
    298        # e.g `C:/python/projects/hassle/src/hassle/templates/pyproject.toml`
    299        # becomes `templates/pyproject.toml`
    300        source_files = [
    301            str(file.separate(name))
    302            for file in (projectdir / "src" / name).rglob("*")
    303            if file.is_file()
    304        ]
    305        return cls(pyproject, projectdir, source_files)
    306
    307    @classmethod
    308    def new(
    309        cls,
    310        targetdir: Pathier,
    311        name: str,
    312        description: str = "",
    313        dependencies: list[str] = [],
    314        keywords: list[str] = [],
    315        source_files: list[str] = [],
    316        add_script: bool = False,
    317        no_license: bool = False,
    318    ) -> Self:
    319        """Create and return a new hassle project."""
    320        pyproject = Pyproject.from_template()
    321        config = HassleConfig.load()
    322        pyproject.project.name = name
    323        pyproject.project.authors = config.authors
    324        pyproject.project.description = description
    325        pyproject.project.dependencies = dependencies
    326        pyproject.project.keywords = keywords
    327        pyproject.project.urls.Homepage = config.project_urls.Homepage.replace(
    328            "$name", name
    329        )
    330        pyproject.project.urls.Documentation = (
    331            config.project_urls.Documentation.replace("$name", name)
    332        )
    333        pyproject.project.urls.Source_code = config.project_urls.Source_code.replace(
    334            "$name", name
    335        )
    336        hassle = cls(pyproject, targetdir, source_files)
    337        if add_script:
    338            hassle.add_script(name, name)
    339        hassle.generate_files()
    340        if no_license:
    341            hassle.pyproject.project.classifiers.pop(1)
    342            (hassle.projectdir / "LICENSE.txt").delete()
    343        hassle.save()
    344        return hassle
    345
    346    def get_template(self, file_name: str) -> str:
    347        """Open are return the content of `{self.templatedir}/{file_name}`."""
    348        return (self.templatedir / file_name).read_text()
    349
    350    def save(self):
    351        """Dump `self.pyproject` to `{self.projectdir}/pyproject.toml`."""
    352        self.pyproject.dump(self.pyproject_path)
    353
    354    def format_source_files(self):
    355        """Use isort and black to format files"""
    356        for file in self.projectdir.rglob("*.py"):
    357            isort.file(file)
    358        try:
    359            black.main([str(self.projectdir)])
    360        except SystemExit as e:
    361            ...
    362        except Exception as e:
    363            raise e
    364
    365    def latest_version_is_published(self) -> bool:
    366        """Check if the current version of this project has been published to pypi.org."""
    367        pypi_url = f"https://pypi.org/project/{self.name}"
    368        response = requests.get(pypi_url)
    369        if response.status_code != 200:
    370            raise RuntimeError(
    371                f"{pypi_url} returned status code {response.status_code} :/"
    372            )
    373        soup = BeautifulSoup(response.text, "html.parser")
    374        header = soup.find("h1", class_="package-header__name")
    375        assert isinstance(header, Tag)
    376        text = header.text.strip()
    377        pypi_version = text[text.rfind(" ") + 1 :]
    378        return self.version == pypi_version
    379
    380    # ====================================================================================
    381    # Updaters ===========================================================================
    382    # ====================================================================================
    383    def add_script(self, name: str, file_stem: str, function: str = "main"):
    384        """Add a script to `pyproject.project.scripts` in the format `{name} = "{package_name}.{file_stem}:{function}"`"""
    385        self.pyproject.project.scripts[name] = f"{self.name}.{file_stem}:{function}"
    386
    387    def update_init_version(self):
    388        """Update the `__version__` in this projects `__init__.py` file
    389        to the current value of `self.pyproject.project.version`
    390        if it exists and has a `__version__` string.
    391
    392        If it doesn't have a `__version__` string, append one to it."""
    393        init_file = self.srcdir / "__init__.py"
    394        version = f'__version__ = "{self.version}"'
    395        if init_file.exists():
    396            content = init_file.read_text()
    397            if "__version__" in content:
    398                lines = content.splitlines()
    399                for i, line in enumerate(lines):
    400                    if line.startswith("__version__"):
    401                        lines[i] = version
    402                content = "\n".join(lines)
    403            else:
    404                content += f"\n{version}"
    405            init_file.write_text(content)
    406
    407    def bump_version(self, bump_type: str):
    408        """Bump the version of this project.
    409
    410        `bump_type` should be `major`, `minor`, or `patch`."""
    411        # bump pyproject version
    412        self.version = utilities.bump_version(self.version, bump_type)
    413        # bump `__version__` in __init__.py if the file exists and has a `__version__`.
    414        self.update_init_version()
    415
    416    def update_dependencies(
    417        self, overwrite_existing_packages: bool, include_versions: bool
    418    ):
    419        """Scan project for dependencies and update the corresponding field in the pyproject model.
    420
    421        If `overwrite_existing_packages` is `False`, this function will only add a package if it isn't already listed,
    422        but won't remove anything currently in the list.
    423        Use this option to preserve manually added dependencies."""
    424        project = packagelister.scan_dir(self.srcdir)
    425        version_conditional = ">=" if include_versions else None
    426        if overwrite_existing_packages:
    427            self.pyproject.project.dependencies = project.get_formatted_requirements(
    428                version_conditional
    429            )
    430        else:
    431            # Only add a package if it isn't already in the dependency list
    432            self.pyproject.project.dependencies.extend(
    433                [
    434                    package.get_formatted_requirement(version_conditional)
    435                    if version_conditional
    436                    else package.distribution_name
    437                    for package in project.requirements
    438                    if all(
    439                        package.distribution_name not in existing_dependency
    440                        for existing_dependency in self.pyproject.project.dependencies
    441                    )
    442                ]
    443            )
    444
    445    def _generate_changelog(self) -> list[str]:
    446        if HassleConfig.exists():
    447            tag_prefix = HassleConfig.load().git.tag_prefix
    448        else:
    449            HassleConfig.warn()
    450            print("Assuming no tag prefix.")
    451            tag_prefix = ""
    452        raw_changelog = [
    453            line
    454            for line in subprocess.run(
    455                [
    456                    "auto-changelog",
    457                    "-p",
    458                    self.projectdir,
    459                    "--tag-prefix",
    460                    tag_prefix,
    461                    "--stdout",
    462                ],
    463                stdout=subprocess.PIPE,
    464                text=True,
    465            ).stdout.splitlines(True)
    466            if not line.startswith(
    467                (
    468                    "Full set of changes:",
    469                    f"* build {tag_prefix}",
    470                    "* update changelog",
    471                )
    472            )
    473        ]
    474        return raw_changelog
    475
    476    def update_changelog(self):
    477        """Update `CHANGELOG.md` by invoking the `auto-changelog` module.
    478
    479        If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed."""
    480        raw_changelog = self._generate_changelog()
    481        # If there's no existing changelog, dump the generated one and get out of here.
    482        if not self.changelog_path.exists():
    483            self.changelog_path.join(raw_changelog)
    484            return
    485
    486        # Don't want to overwrite previously existing manual changes/edits
    487        existing_changelog = self.changelog_path.read_text().splitlines(True)[
    488            2:
    489        ]  # First two elements are "# Changelog\n" and "\n"
    490        new_changes = raw_changelog
    491        for line in existing_changelog:
    492            # Release headers are prefixed with "## "
    493            if line.startswith("## "):
    494                new_changes = raw_changelog[: raw_changelog.index(line)]
    495                break
    496        changes = "".join(new_changes)
    497        # "#### OTHERS" gets added to the changelog even when there's nothing for that category,
    498        # so we'll get rid of it if that's the case
    499        others = "#### Others"
    500        if changes.strip("\n").endswith(others):
    501            changes = changes.strip("\n").replace(others, "\n\n")
    502        # If changes == "# Changelog\n\n" then there weren't actually any new changes
    503        if not changes == "# Changelog\n\n":
    504            self.changelog_path.write_text(changes + "".join(existing_changelog))
    505
    506    # ====================================================================================
    507    # File/Project creation ==============================================================
    508    # ====================================================================================
    509
    510    def create_source_files(self):
    511        """Generate source files in `self.srcdir`."""
    512        for file in self.source_files:
    513            (self.srcdir / file).touch()
    514        init = self.srcdir / "__init__.py"
    515        if init.exists():
    516            init.append(f'__version__ = "{self.version}"')
    517
    518    def create_readme(self):
    519        readme = self.get_template("README.md")
    520        readme = readme.replace("$name", self.name)
    521        readme = readme.replace("$description", self.pyproject.project.description)
    522        (self.projectdir / "README.md").write_text(readme)
    523
    524    def create_license(self):
    525        license_ = self.get_template("license.txt")
    526        license_ = license_.replace("$year", str(datetime.now().year))
    527        (self.projectdir / "LICENSE.txt").write_text(license_)
    528
    529    def create_gitignore(self):
    530        (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")
    531
    532    def create_vscode_settings(self):
    533        self.vsdir.mkdir()
    534        (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json")
    535
    536    def create_tests(self):
    537        (self.testsdir / f"test_{self.name}.py").touch()
    538
    539    def generate_files(self):
    540        """Create all the necessary files.
    541
    542        Note: This will overwrite any existing files."""
    543        self.projectdir.mkdir()
    544        for func in dir(self):
    545            if func.startswith("create_"):
    546                getattr(self, func)()
    547        self.pyproject.dump(self.pyproject_path)
    548
    549    def generate_docs(self):
    550        """Generate docs by invoking `pdoc`"""
    551        self.docsdir.delete()
    552        subprocess.run(["pdoc", "-o", self.docsdir, self.srcdir])

HassleProject( pyproject: hassle.models.Pyproject, projectdir: pathier.pathier.Pathier, source_files: list[str], templatedir: pathier.pathier.Pathier = WindowsPath('E:/1vsCode/python/hassle/src/hassle/templates'))
source_code: str

Join and return all code from any `.py` files in `self.srcdir`.

Useful if a tool needs to scan all the source code for something.

name: str

This package's name.

version: str

This package's version.

@classmethod
def load(cls, projectdir: pathier.pathier.Pathier | pathlib.Path | str) -> Self: View Source

    291    @classmethod
    292    def load(cls, projectdir: Pathish) -> Self:
    293        """Load a project given `projectdir`."""
    294        projectdir = Pathier(projectdir)
    295        pyproject = Pyproject.load(projectdir / "pyproject.toml")
    296        name = pyproject.project.name
    297        # Convert source files to path stems relative to projectdir/src/name
    298        # e.g `C:/python/projects/hassle/src/hassle/templates/pyproject.toml`
    299        # becomes `templates/pyproject.toml`
    300        source_files = [
    301            str(file.separate(name))
    302            for file in (projectdir / "src" / name).rglob("*")
    303            if file.is_file()
    304        ]
    305        return cls(pyproject, projectdir, source_files)

Load a project given `projectdir`.

@classmethod
def new( cls, targetdir: pathier.pathier.Pathier, name: str, description: str = '', dependencies: list[str] = [], keywords: list[str] = [], source_files: list[str] = [], add_script: bool = False, no_license: bool = False) -> Self: View Source

    307    @classmethod
    308    def new(
    309        cls,
    310        targetdir: Pathier,
    311        name: str,
    312        description: str = "",
    313        dependencies: list[str] = [],
    314        keywords: list[str] = [],
    315        source_files: list[str] = [],
    316        add_script: bool = False,
    317        no_license: bool = False,
    318    ) -> Self:
    319        """Create and return a new hassle project."""
    320        pyproject = Pyproject.from_template()
    321        config = HassleConfig.load()
    322        pyproject.project.name = name
    323        pyproject.project.authors = config.authors
    324        pyproject.project.description = description
    325        pyproject.project.dependencies = dependencies
    326        pyproject.project.keywords = keywords
    327        pyproject.project.urls.Homepage = config.project_urls.Homepage.replace(
    328            "$name", name
    329        )
    330        pyproject.project.urls.Documentation = (
    331            config.project_urls.Documentation.replace("$name", name)
    332        )
    333        pyproject.project.urls.Source_code = config.project_urls.Source_code.replace(
    334            "$name", name
    335        )
    336        hassle = cls(pyproject, targetdir, source_files)
    337        if add_script:
    338            hassle.add_script(name, name)
    339        hassle.generate_files()
    340        if no_license:
    341            hassle.pyproject.project.classifiers.pop(1)
    342            (hassle.projectdir / "LICENSE.txt").delete()
    343        hassle.save()
    344        return hassle

Create and return a new hassle project.

def get_template(self, file_name: str) -> str: View Source

    346    def get_template(self, file_name: str) -> str:
    347        """Open are return the content of `{self.templatedir}/{file_name}`."""
    348        return (self.templatedir / file_name).read_text()

Open are return the content of `{self.templatedir}/{file_name}`.

def save(self): View Source

    350    def save(self):
    351        """Dump `self.pyproject` to `{self.projectdir}/pyproject.toml`."""
    352        self.pyproject.dump(self.pyproject_path)

Dump `self.pyproject` to `{self.projectdir}/pyproject.toml`.

def format_source_files(self): View Source

    354    def format_source_files(self):
    355        """Use isort and black to format files"""
    356        for file in self.projectdir.rglob("*.py"):
    357            isort.file(file)
    358        try:
    359            black.main([str(self.projectdir)])
    360        except SystemExit as e:
    361            ...
    362        except Exception as e:
    363            raise e

Use isort and black to format files

def latest_version_is_published(self) -> bool: View Source

    365    def latest_version_is_published(self) -> bool:
    366        """Check if the current version of this project has been published to pypi.org."""
    367        pypi_url = f"https://pypi.org/project/{self.name}"
    368        response = requests.get(pypi_url)
    369        if response.status_code != 200:
    370            raise RuntimeError(
    371                f"{pypi_url} returned status code {response.status_code} :/"
    372            )
    373        soup = BeautifulSoup(response.text, "html.parser")
    374        header = soup.find("h1", class_="package-header__name")
    375        assert isinstance(header, Tag)
    376        text = header.text.strip()
    377        pypi_version = text[text.rfind(" ") + 1 :]
    378        return self.version == pypi_version

Check if the current version of this project has been published to pypi.org.

def add_script(self, name: str, file_stem: str, function: str = 'main'): View Source

    383    def add_script(self, name: str, file_stem: str, function: str = "main"):
    384        """Add a script to `pyproject.project.scripts` in the format `{name} = "{package_name}.{file_stem}:{function}"`"""
    385        self.pyproject.project.scripts[name] = f"{self.name}.{file_stem}:{function}"

Add a script to `pyproject.project.scripts` in the format `{name} = "{package_name}.{file_stem}:{function}"`

def update_init_version(self): View Source

    387    def update_init_version(self):
    388        """Update the `__version__` in this projects `__init__.py` file
    389        to the current value of `self.pyproject.project.version`
    390        if it exists and has a `__version__` string.
    391
    392        If it doesn't have a `__version__` string, append one to it."""
    393        init_file = self.srcdir / "__init__.py"
    394        version = f'__version__ = "{self.version}"'
    395        if init_file.exists():
    396            content = init_file.read_text()
    397            if "__version__" in content:
    398                lines = content.splitlines()
    399                for i, line in enumerate(lines):
    400                    if line.startswith("__version__"):
    401                        lines[i] = version
    402                content = "\n".join(lines)
    403            else:
    404                content += f"\n{version}"
    405            init_file.write_text(content)

Update the `__version__` in this projects `__init__.py` file to the current value of `self.pyproject.project.version` if it exists and has a `__version__` string.

If it doesn't have a `__version__` string, append one to it.

def bump_version(self, bump_type: str): View Source

    407    def bump_version(self, bump_type: str):
    408        """Bump the version of this project.
    409
    410        `bump_type` should be `major`, `minor`, or `patch`."""
    411        # bump pyproject version
    412        self.version = utilities.bump_version(self.version, bump_type)
    413        # bump `__version__` in __init__.py if the file exists and has a `__version__`.
    414        self.update_init_version()

Bump the version of this project.

`bump_type` should be `major`, `minor`, or `patch`.

def update_dependencies(self, overwrite_existing_packages: bool, include_versions: bool): View Source

    416    def update_dependencies(
    417        self, overwrite_existing_packages: bool, include_versions: bool
    418    ):
    419        """Scan project for dependencies and update the corresponding field in the pyproject model.
    420
    421        If `overwrite_existing_packages` is `False`, this function will only add a package if it isn't already listed,
    422        but won't remove anything currently in the list.
    423        Use this option to preserve manually added dependencies."""
    424        project = packagelister.scan_dir(self.srcdir)
    425        version_conditional = ">=" if include_versions else None
    426        if overwrite_existing_packages:
    427            self.pyproject.project.dependencies = project.get_formatted_requirements(
    428                version_conditional
    429            )
    430        else:
    431            # Only add a package if it isn't already in the dependency list
    432            self.pyproject.project.dependencies.extend(
    433                [
    434                    package.get_formatted_requirement(version_conditional)
    435                    if version_conditional
    436                    else package.distribution_name
    437                    for package in project.requirements
    438                    if all(
    439                        package.distribution_name not in existing_dependency
    440                        for existing_dependency in self.pyproject.project.dependencies
    441                    )
    442                ]
    443            )

Scan project for dependencies and update the corresponding field in the pyproject model.

If `overwrite_existing_packages` is `False`, this function will only add a package if it isn't already listed, but won't remove anything currently in the list. Use this option to preserve manually added dependencies.

def update_changelog(self): View Source

    476    def update_changelog(self):
    477        """Update `CHANGELOG.md` by invoking the `auto-changelog` module.
    478
    479        If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed."""
    480        raw_changelog = self._generate_changelog()
    481        # If there's no existing changelog, dump the generated one and get out of here.
    482        if not self.changelog_path.exists():
    483            self.changelog_path.join(raw_changelog)
    484            return
    485
    486        # Don't want to overwrite previously existing manual changes/edits
    487        existing_changelog = self.changelog_path.read_text().splitlines(True)[
    488            2:
    489        ]  # First two elements are "# Changelog\n" and "\n"
    490        new_changes = raw_changelog
    491        for line in existing_changelog:
    492            # Release headers are prefixed with "## "
    493            if line.startswith("## "):
    494                new_changes = raw_changelog[: raw_changelog.index(line)]
    495                break
    496        changes = "".join(new_changes)
    497        # "#### OTHERS" gets added to the changelog even when there's nothing for that category,
    498        # so we'll get rid of it if that's the case
    499        others = "#### Others"
    500        if changes.strip("\n").endswith(others):
    501            changes = changes.strip("\n").replace(others, "\n\n")
    502        # If changes == "# Changelog\n\n" then there weren't actually any new changes
    503        if not changes == "# Changelog\n\n":
    504            self.changelog_path.write_text(changes + "".join(existing_changelog))

Update `CHANGELOG.md` by invoking the `auto-changelog` module.

If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed.

def create_source_files(self): View Source

    510    def create_source_files(self):
    511        """Generate source files in `self.srcdir`."""
    512        for file in self.source_files:
    513            (self.srcdir / file).touch()
    514        init = self.srcdir / "__init__.py"
    515        if init.exists():
    516            init.append(f'__version__ = "{self.version}"')

Generate source files in `self.srcdir`.

def create_readme(self): View Source

    518    def create_readme(self):
    519        readme = self.get_template("README.md")
    520        readme = readme.replace("$name", self.name)
    521        readme = readme.replace("$description", self.pyproject.project.description)
    522        (self.projectdir / "README.md").write_text(readme)

def create_license(self): View Source

    524    def create_license(self):
    525        license_ = self.get_template("license.txt")
    526        license_ = license_.replace("$year", str(datetime.now().year))
    527        (self.projectdir / "LICENSE.txt").write_text(license_)

def create_gitignore(self): View Source

    529    def create_gitignore(self):
    530        (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")

def create_vscode_settings(self): View Source

    532    def create_vscode_settings(self):
    533        self.vsdir.mkdir()
    534        (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json")

def create_tests(self): View Source

    536    def create_tests(self):
    537        (self.testsdir / f"test_{self.name}.py").touch()

def generate_files(self): View Source

    539    def generate_files(self):
    540        """Create all the necessary files.
    541
    542        Note: This will overwrite any existing files."""
    543        self.projectdir.mkdir()
    544        for func in dir(self):
    545            if func.startswith("create_"):
    546                getattr(self, func)()
    547        self.pyproject.dump(self.pyproject_path)

Create all the necessary files.

Note: This will overwrite any existing files.

def generate_docs(self): View Source

    549    def generate_docs(self):
    550        """Generate docs by invoking `pdoc`"""
    551        self.docsdir.delete()
    552        subprocess.run(["pdoc", "-o", self.docsdir, self.srcdir])

Generate docs by invoking `pdoc`