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