hassle.models

  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:
21@dataclass
22class Sdist:
23    exclude: list[str]
Sdist(exclude: list[str])
@dataclass
class Targets:
26@dataclass
27class Targets:
28    sdist: Sdist
Targets(sdist: hassle.models.Sdist)
@dataclass
class Build:
31@dataclass
32class Build:
33    targets: Targets
Build(targets: hassle.models.Targets)
@dataclass
class BuildSystem:
36@dataclass
37class BuildSystem:
38    requires: list[str]
39    build_backend: str
BuildSystem(requires: list[str], build_backend: str)
@dataclass
class Urls:
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:
49@dataclass
50class Author:
51    name: str = ""
52    email: str = ""
Author(name: str = '', email: str = '')
@dataclass
class Git:
55@dataclass
56class Git:
57    tag_prefix: str = ""
Git(tag_prefix: str = '')
@dataclass
class IniOptions:
60@dataclass
61class IniOptions:
62    addopts: list[str]
63    pythonpath: str
IniOptions(addopts: list[str], pythonpath: str)
@dataclass
class Pytest:
66@dataclass
67class Pytest:
68    ini_options: IniOptions
Pytest(ini_options: hassle.models.IniOptions)
@dataclass
class Hatch:
71@dataclass
72class Hatch:
73    build: Build
Hatch(build: hassle.models.Build)
@dataclass
class Tool:
76@dataclass
77class Tool:
78    pytest: Pytest
79    hatch: Hatch
@dataclass
class Project:
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:
 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:
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')):
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:
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:
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:
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')):
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():
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:
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')):
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:
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:
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:
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:
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):
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):
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:
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'):
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):
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):
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):
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):
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):
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):
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):
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):
529    def create_gitignore(self):
530        (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")
def create_vscode_settings(self):
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):
536    def create_tests(self):
537        (self.testsdir / f"test_{self.name}.py").touch()
def generate_files(self):
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):
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