Coverage for src\hassle\models.py: 23%

314 statements  

« prev     ^ index     » next       coverage.py v7.2.2, created at 2024-01-12 14:55 -0600

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 project = packagelister.scan_dir(self.projectdir) 

420 version_conditional = "~=" if include_versions else None 

421 if overwrite_existing_packages: 

422 self.pyproject.project.dependencies = project.get_formatted_requirements( 

423 version_conditional 

424 ) 

425 else: 

426 # Only add a package if it isn't already in the dependency list 

427 self.pyproject.project.dependencies.extend( 

428 [ 

429 package.get_formatted_requirement(version_conditional) 

430 if version_conditional 

431 else package.distribution_name 

432 for package in project.requirements 

433 if all( 

434 package.distribution_name not in existing_dependency 

435 for existing_dependency in self.pyproject.project.dependencies 

436 ) 

437 ] 

438 ) 

439 

440 def _generate_changelog(self) -> list[str]: 

441 if HassleConfig.exists(): 

442 tag_prefix = HassleConfig.load().git.tag_prefix 

443 else: 

444 HassleConfig.warn() 

445 print("Assuming no tag prefix.") 

446 tag_prefix = "" 

447 raw_changelog = [ 

448 line 

449 for line in subprocess.run( 

450 [ 

451 "auto-changelog", 

452 "-p", 

453 self.projectdir, 

454 "--tag-prefix", 

455 tag_prefix, 

456 "--stdout", 

457 ], 

458 stdout=subprocess.PIPE, 

459 text=True, 

460 ).stdout.splitlines(True) 

461 if not line.startswith( 

462 ( 

463 "Full set of changes:", 

464 f"* build {tag_prefix}", 

465 "* update changelog", 

466 ) 

467 ) 

468 ] 

469 return raw_changelog 

470 

471 def update_changelog(self): 

472 """Update `CHANGELOG.md` by invoking the `auto-changelog` module. 

473 

474 If `hassle_config.toml` doesn't exist, an empty tag prefix will be assumed.""" 

475 raw_changelog = self._generate_changelog() 

476 # If there's no existing changelog, dump the generated one and get out of here. 

477 if not self.changelog_path.exists(): 

478 self.changelog_path.join(raw_changelog) 

479 return 

480 

481 # Don't want to overwrite previously existing manual changes/edits 

482 existing_changelog = self.changelog_path.read_text().splitlines(True)[ 

483 2: 

484 ] # First two elements are "# Changelog\n" and "\n" 

485 new_changes = raw_changelog 

486 for line in existing_changelog: 

487 # Release headers are prefixed with "## " 

488 if line.startswith("## "): 

489 new_changes = raw_changelog[: raw_changelog.index(line)] 

490 break 

491 changes = "".join(new_changes) 

492 # "#### OTHERS" gets added to the changelog even when there's nothing for that category, 

493 # so we'll get rid of it if that's the case 

494 others = "#### Others" 

495 if changes.strip("\n").endswith(others): 

496 changes = changes.strip("\n").replace(others, "\n\n") 

497 # If changes == "# Changelog\n\n" then there weren't actually any new changes 

498 if not changes == "# Changelog\n\n": 

499 self.changelog_path.write_text(changes + "".join(existing_changelog)) 

500 

501 # ==================================================================================== 

502 # File/Project creation ============================================================== 

503 # ==================================================================================== 

504 

505 def create_source_files(self): 

506 """Generate source files in `self.srcdir`.""" 

507 for file in self.source_files: 

508 (self.srcdir / file).touch() 

509 init = self.srcdir / "__init__.py" 

510 if init.exists(): 

511 init.append(f'__version__ = "{self.version}"') 

512 

513 def create_readme(self): 

514 readme = self.get_template("README.md") 

515 readme = readme.replace("$name", self.name) 

516 readme = readme.replace("$description", self.pyproject.project.description) 

517 (self.projectdir / "README.md").write_text(readme) 

518 

519 def create_license(self): 

520 license_ = self.get_template("license.txt") 

521 license_ = license_.replace("$year", str(datetime.now().year)) 

522 (self.projectdir / "LICENSE.txt").write_text(license_) 

523 

524 def create_gitignore(self): 

525 (self.templatedir / ".gitignore.txt").copy(self.projectdir / ".gitignore") 

526 

527 def create_vscode_settings(self): 

528 self.vsdir.mkdir() 

529 (self.templatedir / "vscode_settings.json").copy(self.vsdir / "settings.json") 

530 

531 def create_tests(self): 

532 (self.testsdir / f"test_{self.name}.py").touch() 

533 

534 def generate_files(self): 

535 """Create all the necessary files. 

536 

537 Note: This will overwrite any existing files.""" 

538 self.projectdir.mkdir() 

539 for func in dir(self): 

540 if func.startswith("create_"): 

541 getattr(self, func)() 

542 self.pyproject.dump(self.pyproject_path) 

543 

544 def generate_docs(self): 

545 """Generate docs by invoking `pdoc`""" 

546 self.docsdir.delete() 

547 subprocess.run(["pdoc", "-o", self.docsdir, self.srcdir])