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])
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)
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")
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
.
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
.
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
.
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)
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
.
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
.
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.
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])
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.
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
.
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.
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}
.
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
.
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
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.
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}"
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.
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
.
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.
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.
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
.
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.