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

1 import subprocess

2 from dataclasses import asdict, dataclass, field

3 from datetime import datetime

4 from functools import cached_property

5

6 import black

7 import dacite

8 import isort

9 import requests

10 from bs4 import BeautifulSoup, Tag

11 from packagelister import packagelister

12 from pathier import Pathier, Pathish

13 from typing_extensions import Self

14

15 from hassle import utilities

16

17 root = Pathier(__file__).parent

18

19

20@dataclass

21 class Sdist:

22 exclude: list[str]

23

24

25@dataclass

26 class Targets:

27 sdist: Sdist

28

29

30@dataclass

31 class Build:

32 targets: Targets

33

34

35@dataclass

36 class BuildSystem:

37 requires: list[str]

38 build_backend: str

39

40

41@dataclass

42 class Urls:

43 Homepage: str = ""

44 Documentation: str = ""

45 Source_code: str = ""

46

47

48@dataclass

49 class Author:

50 name: str = ""

51 email: str = ""

52

53

54@dataclass

55 class Git:

56 tag_prefix: str = ""

57

58

59@dataclass

60 class IniOptions:

61 addopts: list[str]

62 pythonpath: str

63

64

65@dataclass

66 class Pytest:

67 ini_options: IniOptions

68

69

70@dataclass

71 class Hatch:

72 build: Build

73

74

75@dataclass

76 class Tool:

77 pytest: Pytest

78 hatch: Hatch

79

80

81@dataclass

82 class 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

97 class 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

160 class 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

235 class 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])