Coverage for src\hassle\models.py: 68%
314 statements
« prev ^ index » next coverage.py v7.2.2, created at 2024-02-15 19:47 -0600
« prev ^ index » next coverage.py v7.2.2, created at 2024-02-15 19:47 -0600
1import subprocess
2from dataclasses import asdict, dataclass, field
3from datetime import datetime
4from functools import cached_property
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
15from hassle import utilities
17root = Pathier(__file__).parent
20@dataclass
21class Sdist:
22 exclude: list[str]
25@dataclass
26class Targets:
27 sdist: Sdist
30@dataclass
31class Build:
32 targets: Targets
35@dataclass
36class BuildSystem:
37 requires: list[str]
38 build_backend: str
41@dataclass
42class Urls:
43 Homepage: str = ""
44 Documentation: str = ""
45 Source_code: str = ""
48@dataclass
49class Author:
50 name: str = ""
51 email: str = ""
54@dataclass
55class Git:
56 tag_prefix: str = ""
59@dataclass
60class IniOptions:
61 addopts: list[str]
62 pythonpath: str
65@dataclass
66class Pytest:
67 ini_options: IniOptions
70@dataclass
71class Hatch:
72 build: Build
75@dataclass
76class Tool:
77 pytest: Pytest
78 hatch: Hatch
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)
96@dataclass
97class Pyproject:
98 build_system: BuildSystem
99 project: Project
100 tool: Tool
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 )
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 )
138 return data
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)
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)
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")
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)
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)
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)
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.")
195 @staticmethod
196 def exists(path: Pathish = Pathier(__file__).parent / "hassle_config.toml") -> bool:
197 return Pathier(path).exists()
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)
234@dataclass
235class HassleProject:
236 pyproject: Pyproject
237 projectdir: Pathier
238 source_files: list[str]
239 templatedir: Pathier = root / "templates"
241 @property
242 def source_code(self) -> str:
243 """Join and return all code from any `.py` files in `self.srcdir`.
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"))
248 @cached_property
249 def srcdir(self) -> Pathier:
250 return self.projectdir / "src" / self.pyproject.project.name
252 @cached_property
253 def changelog_path(self) -> Pathier:
254 return self.projectdir / "CHANGELOG.md"
256 @cached_property
257 def pyproject_path(self) -> Pathier:
258 return self.projectdir / "pyproject.toml"
260 @cached_property
261 def docsdir(self) -> Pathier:
262 return self.projectdir / "docs"
264 @cached_property
265 def testsdir(self) -> Pathier:
266 return self.projectdir / "tests"
268 @cached_property
269 def vsdir(self) -> Pathier:
270 return self.projectdir / ".vscode"
272 @cached_property
273 def distdir(self) -> Pathier:
274 return self.projectdir / "dist"
276 @property
277 def name(self) -> str:
278 """This package's name."""
279 return self.pyproject.project.name
281 @property
282 def version(self) -> str:
283 """This package's version."""
284 return self.pyproject.project.version
286 @version.setter
287 def version(self, new_version: str):
288 self.pyproject.project.version = new_version
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)
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
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()
349 def save(self):
350 """Dump `self.pyproject` to `{self.projectdir}/pyproject.toml`."""
351 self.pyproject.dump(self.pyproject_path)
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
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
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}"
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.
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)
406 def bump_version(self, bump_type: str):
407 """Bump the version of this project.
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()
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.
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 )
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
475 def update_changelog(self):
476 """Update `CHANGELOG.md` by invoking the `auto-changelog` module.
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
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))
505 # ====================================================================================
506 # File/Project creation ==============================================================
507 # ====================================================================================
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}"')
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)
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_)
528 def create_gitignore(self):
529 (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore")
531 def create_vscode_settings(self):
532 self.vsdir.mkdir()
533 (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json")
535 def create_tests(self):
536 (self.testsdir / f"test_{self.name}.py").touch()
538 def generate_files(self):
539 """Create all the necessary files.
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)
548 def generate_docs(self):
549 """Generate docs by invoking `pdoc`"""
550 self.docsdir.delete()
551 subprocess.run(["pdoc", "-o", self.docsdir, self.srcdir])