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