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

Return a datamodel object populated from path.

def dump( self, path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('pyproject.toml')):
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)

Write the contents of this datamodel object to path.

@classmethod
def from_template(cls) -> Self:
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")

Return a Pyproject object using templates/pyproject_template.toml.

@dataclass
class HassleConfig:
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)
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:
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)

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')):
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)

Write the contents of this datamodel object to path.

@staticmethod
def warn():
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.")
@staticmethod
def exists( path: pathier.pathier.Pathier | pathlib.Path | str = WindowsPath('E:/1vsCode/python/hassle/src/hassle/hassle_config.toml')) -> bool:
195    @staticmethod
196    def exists(path: Pathish = Pathier(__file__).parent / "hassle_config.toml") -> bool:
197        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')):
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)

Create or edit hassle_config.toml from given params.

@dataclass
class HassleProject:
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        dependencies = utilities.get_dependencies(self.projectdir)
420        if overwrite_existing_packages:
421            self.pyproject.project.dependencies = [
422                utilities.format_dependency(dependency, include_versions)
423                for dependency in dependencies
424            ]
425        else:
426            for dependency in dependencies:
427                if all(
428                    dependency[0] not in existing_dependency
429                    for existing_dependency in self.pyproject.project.dependencies
430                ):
431                    self.pyproject.project.dependencies.append(
432                        utilities.format_dependency(dependency, include_versions)
433                    )
434
435    def _generate_changelog(self) -> list[str]:
436        if HassleConfig.exists():
437            tag_prefix = HassleConfig.load().git.tag_prefix
438        else:
439            HassleConfig.warn()
440            print("Assuming no tag prefix.")
441            tag_prefix = ""
442        raw_changelog = [
443            line
444            for line in subprocess.run(
445                [
446                    "auto-changelog",
447                    "-p",
448                    self.projectdir,
449                    "--tag-prefix",
450                    tag_prefix,
451                    "--stdout",
452                ],
453                stdout=subprocess.PIPE,
454                text=True,
455            ).stdout.splitlines(True)
456            if not line.startswith(
457                (
458                    "Full set of changes:",
459                    f"* build {tag_prefix}",
460                    "* update changelog",
461                )
462            )
463        ]
464        return raw_changelog
465
466    def update_changelog(self):
467        """Update `CHANGELOG.md` by invoking the `auto-changelog` module.
468
469        If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed."""
470        raw_changelog = self._generate_changelog()
471        # If there's no existing changelog, dump the generated one and get out of here.
472        if not self.changelog_path.exists():
473            self.changelog_path.write_text(raw_changelog)
474            return
475
476        # Don't want to overwrite previously existing manual changes/edits
477        existing_changelog = self.changelog_path.read_text().splitlines(True)[
478            2:
479        ]  # First two elements are "# Changelog\n" and "\n"
480        new_changes = raw_changelog
481        for line in existing_changelog:
482            # Release headers are prefixed with "## "
483            if line.startswith("## "):
484                new_changes = raw_changelog[: raw_changelog.index(line)]
485                break
486        changes = "".join(new_changes)
487        # If changes == "# Changelog\n\n" then there weren't actually any new changes
488        if not changes == "# Changelog\n\n":
489            self.changelog_path.write_text(changes + "".join(existing_changelog))
490
491    # ====================================================================================
492    # File/Project creation ==============================================================
493    # ====================================================================================
494
495    def create_source_files(self):
496        """Generate source files in `self.srcdir`."""
497        for file in self.source_files:
498            (self.srcdir / file).touch()
499        init = self.srcdir / "__init__.py"
500        if init.exists():
501            init.append(f'__version__ = "{self.version}"')
502
503    def create_readme(self):
504        readme = self.get_template("README.md")
505        readme = readme.replace("$name", self.name)
506        readme = readme.replace("$description", self.pyproject.project.description)
507        (self.projectdir / "README.md").write_text(readme)
508
509    def create_license(self):
510        license_ = self.get_template("license.txt")
511        license_ = license_.replace("$year", str(datetime.now().year))
512        (self.projectdir / "LICENSE.txt").write_text(license_)
513
514    def create_gitignore(self):
515        (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")
516
517    def create_vscode_settings(self):
518        self.vsdir.mkdir()
519        (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json")
520
521    def create_tests(self):
522        (self.testsdir / f"test_{self.name}.py").touch()
523
524    def generate_files(self):
525        """Create all the necessary files.
526
527        Note: This will overwrite any existing files."""
528        self.projectdir.mkdir()
529        for func in dir(self):
530            if func.startswith("create_"):
531                getattr(self, func)()
532        self.pyproject.dump(self.pyproject_path)
533
534    def generate_docs(self):
535        """Generate docs by invoking `pdoc`"""
536        self.docsdir.delete()
537        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:
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)

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

Create and return a new hassle project.

def get_template(self, file_name: str) -> str:
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()

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

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

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

def format_source_files(self):
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

Use isort and black to format files

def latest_version_is_published(self) -> bool:
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

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'):
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}"

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

def update_init_version(self):
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)

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):
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()

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):
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        dependencies = utilities.get_dependencies(self.projectdir)
420        if overwrite_existing_packages:
421            self.pyproject.project.dependencies = [
422                utilities.format_dependency(dependency, include_versions)
423                for dependency in dependencies
424            ]
425        else:
426            for dependency in dependencies:
427                if all(
428                    dependency[0] not in existing_dependency
429                    for existing_dependency in self.pyproject.project.dependencies
430                ):
431                    self.pyproject.project.dependencies.append(
432                        utilities.format_dependency(dependency, include_versions)
433                    )

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

def update_changelog(self):
466    def update_changelog(self):
467        """Update `CHANGELOG.md` by invoking the `auto-changelog` module.
468
469        If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed."""
470        raw_changelog = self._generate_changelog()
471        # If there's no existing changelog, dump the generated one and get out of here.
472        if not self.changelog_path.exists():
473            self.changelog_path.write_text(raw_changelog)
474            return
475
476        # Don't want to overwrite previously existing manual changes/edits
477        existing_changelog = self.changelog_path.read_text().splitlines(True)[
478            2:
479        ]  # First two elements are "# Changelog\n" and "\n"
480        new_changes = raw_changelog
481        for line in existing_changelog:
482            # Release headers are prefixed with "## "
483            if line.startswith("## "):
484                new_changes = raw_changelog[: raw_changelog.index(line)]
485                break
486        changes = "".join(new_changes)
487        # If changes == "# Changelog\n\n" then there weren't actually any new changes
488        if not changes == "# Changelog\n\n":
489            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):
495    def create_source_files(self):
496        """Generate source files in `self.srcdir`."""
497        for file in self.source_files:
498            (self.srcdir / file).touch()
499        init = self.srcdir / "__init__.py"
500        if init.exists():
501            init.append(f'__version__ = "{self.version}"')

Generate source files in self.srcdir.

def create_readme(self):
503    def create_readme(self):
504        readme = self.get_template("README.md")
505        readme = readme.replace("$name", self.name)
506        readme = readme.replace("$description", self.pyproject.project.description)
507        (self.projectdir / "README.md").write_text(readme)
def create_license(self):
509    def create_license(self):
510        license_ = self.get_template("license.txt")
511        license_ = license_.replace("$year", str(datetime.now().year))
512        (self.projectdir / "LICENSE.txt").write_text(license_)
def create_gitignore(self):
514    def create_gitignore(self):
515        (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")
def create_vscode_settings(self):
517    def create_vscode_settings(self):
518        self.vsdir.mkdir()
519        (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json")
def create_tests(self):
521    def create_tests(self):
522        (self.testsdir / f"test_{self.name}.py").touch()
def generate_files(self):
524    def generate_files(self):
525        """Create all the necessary files.
526
527        Note: This will overwrite any existing files."""
528        self.projectdir.mkdir()
529        for func in dir(self):
530            if func.startswith("create_"):
531                getattr(self, func)()
532        self.pyproject.dump(self.pyproject_path)

Create all the necessary files.

Note: This will overwrite any existing files.

def generate_docs(self):
534    def generate_docs(self):
535        """Generate docs by invoking `pdoc`"""
536        self.docsdir.delete()
537        subprocess.run(["pdoc", "-o", self.docsdir, self.srcdir])

Generate docs by invoking pdoc