hassle.hassle_utilities
1import os 2import subprocess 3 4import black 5import packagelister 6import requests 7import vermin 8from bs4 import BeautifulSoup 9from gitbetter import Git 10from pathier import Pathier 11 12from hassle import hassle_config 13 14root = Pathier(__file__).parent 15 16 17def increment_version(pyproject_path: Pathier, increment_type: str): 18 """Increment the project.version field in pyproject.toml. 19 20 :param package_path: Path to the package/project directory. 21 22 :param increment_type: One from 'major', 'minor', or 'patch'.""" 23 meta = pyproject_path.loads() 24 major, minor, patch = [int(num) for num in meta["project"]["version"].split(".")] 25 if increment_type == "major": 26 major += 1 27 minor = 0 28 patch = 0 29 elif increment_type == "minor": 30 minor += 1 31 patch = 0 32 elif increment_type == "patch": 33 patch += 1 34 incremented_version = ".".join(str(num) for num in [major, minor, patch]) 35 meta["project"]["version"] = incremented_version 36 pyproject_path.dumps(meta) 37 38 39def get_minimum_py_version(src: str) -> str: 40 """Scan src with vermin and return minimum 41 python version.""" 42 config = vermin.Config() 43 config.add_backport("typing") 44 config.add_backport("typing_extensions") 45 config.set_eval_annotations(True) 46 result = vermin.visit(src, config).minimum_versions()[1] 47 return f"{result[0]}.{result[1]}" 48 49 50def get_project_code(project_path: Pathier) -> str: 51 """Read and return all code from project_path 52 as one string.""" 53 return "\n".join(file.read_text() for file in project_path.rglob("*.py")) 54 55 56def update_minimum_python_version(pyproject_path: Pathier): 57 """Use vermin to determine the minimum compatible 58 Python version and update the corresponding field 59 in pyproject.toml.""" 60 project_code = get_project_code(pyproject_path.parent / "src") 61 meta = pyproject_path.loads() 62 minimum_version = get_minimum_py_version(project_code) 63 minimum_version = f">={minimum_version}" 64 meta["project"]["requires-python"] = minimum_version 65 pyproject_path.dumps(meta) 66 67 68def generate_docs(package_path: Pathier): 69 """Generate project documentation using pdoc.""" 70 try: 71 (package_path / "docs").delete() 72 except Exception as e: 73 pass 74 os.system( 75 f"pdoc -o {package_path / 'docs'} {package_path / 'src' / package_path.stem}" 76 ) 77 78 79def update_dependencies( 80 pyproject_path: Pathier, overwrite: bool, include_versions: bool = False 81): 82 """Update dependencies list in pyproject.toml. 83 84 :param overwrite: If True, replace the dependencies in pyproject.toml 85 with the results of packagelister.scan() . 86 If False, packages returned by packagelister are appended to 87 the current dependencies in pyproject.toml if they don't already 88 exist in the field.""" 89 packages = packagelister.scan(pyproject_path.parent) 90 91 packages = [ 92 f"{package}~={packages[package]['version']}" 93 if packages[package]["version"] and include_versions 94 else f"{package}" 95 for package in packages 96 if package != pyproject_path.parent.stem 97 ] 98 packages = [ 99 package.replace("speech_recognition", "speechRecognition") 100 for package in packages 101 ] 102 meta = pyproject_path.loads() 103 if overwrite: 104 meta["project"]["dependencies"] = packages 105 else: 106 for package in packages: 107 if "~" in package: 108 name = package.split("~")[0] 109 elif "=" in package: 110 name = package.split("=")[0] 111 else: 112 name = package 113 if all( 114 name not in dependency for dependency in meta["project"]["dependencies"] 115 ): 116 meta["project"]["dependencies"].append(package) 117 pyproject_path.dumps(meta) 118 119 120def update_changelog(pyproject_path: Pathier): 121 """Update project changelog.""" 122 if hassle_config.config_exists(): 123 config = hassle_config.load_config() 124 else: 125 hassle_config.warn() 126 print("Creating blank hassle_config.toml...") 127 config = hassle_config.load_config() 128 changelog_path = pyproject_path.parent / "CHANGELOG.md" 129 raw_changelog = [ 130 line 131 for line in subprocess.run( 132 [ 133 "auto-changelog", 134 "-p", 135 pyproject_path.parent, 136 "--tag-prefix", 137 config["git"]["tag_prefix"], 138 "--stdout", 139 ], 140 stdout=subprocess.PIPE, 141 text=True, 142 ).stdout.splitlines(True) 143 if not line.startswith( 144 ( 145 "Full set of changes:", 146 f"* build {config['git']['tag_prefix']}", 147 "* update changelog", 148 ) 149 ) 150 ] 151 if changelog_path.exists(): 152 previous_changelog = changelog_path.read_text().splitlines(True)[ 153 2: 154 ] # First two elements are "# Changelog\n" and "\n" 155 for line in previous_changelog: 156 # Release headers are prefixed with "## " 157 if line.startswith("## "): 158 new_changes = raw_changelog[: raw_changelog.index(line)] 159 break 160 else: 161 new_changes = raw_changelog 162 previous_changelog = [] 163 # if new_changes == "# Changelog\n\n" then there were no new changes 164 if not "".join(new_changes) == "# Changelog\n\n": 165 changelog_path.write_text("".join(new_changes + previous_changelog)) 166 167 168def tag_version(package_path: Pathier): 169 """Add a git tag corresponding to the version number in pyproject.toml.""" 170 if hassle_config.config_exists(): 171 tag_prefix = hassle_config.load_config()["git"]["tag_prefix"] 172 else: 173 hassle_config.warn() 174 tag_prefix = "" 175 version = (package_path / "pyproject.toml").loads()["project"]["version"] 176 os.chdir(package_path) 177 git = Git() 178 git.tag(f"{tag_prefix}{version}") 179 180 181def format_files(path: Pathier): 182 """Use `Black` to format file(s).""" 183 try: 184 black.main([str(path)]) 185 except SystemExit: 186 ... 187 188 189def on_primary_branch() -> bool: 190 """Returns `False` if repo is not currently on `main` or `master` branch.""" 191 git = Git(True) 192 if git.current_branch not in ["main", "master"]: 193 return False 194 return True 195 196 197def latest_version_is_published(pyproject_path: Pathier) -> bool: 198 """Return `True` if the version number in `pyproject.toml` and the project page on `pypi.org` agree.""" 199 data = pyproject_path.loads() 200 name = data["project"]["name"] 201 version = data["project"]["version"] 202 pypi_url = f"https://pypi.org/project/{name}" 203 response = requests.get(pypi_url) 204 if response.status_code != 200: 205 raise RuntimeError(f"{pypi_url} returned status code {response.status_code} :/") 206 soup = BeautifulSoup(response.text, "html.parser") 207 header = soup.find("h1", class_="package-header__name").text.strip() 208 pypi_version = header[header.rfind(" ") + 1 :] 209 return version == pypi_version
18def increment_version(pyproject_path: Pathier, increment_type: str): 19 """Increment the project.version field in pyproject.toml. 20 21 :param package_path: Path to the package/project directory. 22 23 :param increment_type: One from 'major', 'minor', or 'patch'.""" 24 meta = pyproject_path.loads() 25 major, minor, patch = [int(num) for num in meta["project"]["version"].split(".")] 26 if increment_type == "major": 27 major += 1 28 minor = 0 29 patch = 0 30 elif increment_type == "minor": 31 minor += 1 32 patch = 0 33 elif increment_type == "patch": 34 patch += 1 35 incremented_version = ".".join(str(num) for num in [major, minor, patch]) 36 meta["project"]["version"] = incremented_version 37 pyproject_path.dumps(meta)
Increment the project.version field in pyproject.toml.
Parameters
package_path: Path to the package/project directory.
increment_type: One from 'major', 'minor', or 'patch'.
40def get_minimum_py_version(src: str) -> str: 41 """Scan src with vermin and return minimum 42 python version.""" 43 config = vermin.Config() 44 config.add_backport("typing") 45 config.add_backport("typing_extensions") 46 config.set_eval_annotations(True) 47 result = vermin.visit(src, config).minimum_versions()[1] 48 return f"{result[0]}.{result[1]}"
Scan src with vermin and return minimum python version.
51def get_project_code(project_path: Pathier) -> str: 52 """Read and return all code from project_path 53 as one string.""" 54 return "\n".join(file.read_text() for file in project_path.rglob("*.py"))
Read and return all code from project_path as one string.
57def update_minimum_python_version(pyproject_path: Pathier): 58 """Use vermin to determine the minimum compatible 59 Python version and update the corresponding field 60 in pyproject.toml.""" 61 project_code = get_project_code(pyproject_path.parent / "src") 62 meta = pyproject_path.loads() 63 minimum_version = get_minimum_py_version(project_code) 64 minimum_version = f">={minimum_version}" 65 meta["project"]["requires-python"] = minimum_version 66 pyproject_path.dumps(meta)
Use vermin to determine the minimum compatible Python version and update the corresponding field in pyproject.toml.
69def generate_docs(package_path: Pathier): 70 """Generate project documentation using pdoc.""" 71 try: 72 (package_path / "docs").delete() 73 except Exception as e: 74 pass 75 os.system( 76 f"pdoc -o {package_path / 'docs'} {package_path / 'src' / package_path.stem}" 77 )
Generate project documentation using pdoc.
80def update_dependencies( 81 pyproject_path: Pathier, overwrite: bool, include_versions: bool = False 82): 83 """Update dependencies list in pyproject.toml. 84 85 :param overwrite: If True, replace the dependencies in pyproject.toml 86 with the results of packagelister.scan() . 87 If False, packages returned by packagelister are appended to 88 the current dependencies in pyproject.toml if they don't already 89 exist in the field.""" 90 packages = packagelister.scan(pyproject_path.parent) 91 92 packages = [ 93 f"{package}~={packages[package]['version']}" 94 if packages[package]["version"] and include_versions 95 else f"{package}" 96 for package in packages 97 if package != pyproject_path.parent.stem 98 ] 99 packages = [ 100 package.replace("speech_recognition", "speechRecognition") 101 for package in packages 102 ] 103 meta = pyproject_path.loads() 104 if overwrite: 105 meta["project"]["dependencies"] = packages 106 else: 107 for package in packages: 108 if "~" in package: 109 name = package.split("~")[0] 110 elif "=" in package: 111 name = package.split("=")[0] 112 else: 113 name = package 114 if all( 115 name not in dependency for dependency in meta["project"]["dependencies"] 116 ): 117 meta["project"]["dependencies"].append(package) 118 pyproject_path.dumps(meta)
Update dependencies list in pyproject.toml.
Parameters
- overwrite: If True, replace the dependencies in pyproject.toml with the results of packagelister.scan() . If False, packages returned by packagelister are appended to the current dependencies in pyproject.toml if they don't already exist in the field.
121def update_changelog(pyproject_path: Pathier): 122 """Update project changelog.""" 123 if hassle_config.config_exists(): 124 config = hassle_config.load_config() 125 else: 126 hassle_config.warn() 127 print("Creating blank hassle_config.toml...") 128 config = hassle_config.load_config() 129 changelog_path = pyproject_path.parent / "CHANGELOG.md" 130 raw_changelog = [ 131 line 132 for line in subprocess.run( 133 [ 134 "auto-changelog", 135 "-p", 136 pyproject_path.parent, 137 "--tag-prefix", 138 config["git"]["tag_prefix"], 139 "--stdout", 140 ], 141 stdout=subprocess.PIPE, 142 text=True, 143 ).stdout.splitlines(True) 144 if not line.startswith( 145 ( 146 "Full set of changes:", 147 f"* build {config['git']['tag_prefix']}", 148 "* update changelog", 149 ) 150 ) 151 ] 152 if changelog_path.exists(): 153 previous_changelog = changelog_path.read_text().splitlines(True)[ 154 2: 155 ] # First two elements are "# Changelog\n" and "\n" 156 for line in previous_changelog: 157 # Release headers are prefixed with "## " 158 if line.startswith("## "): 159 new_changes = raw_changelog[: raw_changelog.index(line)] 160 break 161 else: 162 new_changes = raw_changelog 163 previous_changelog = [] 164 # if new_changes == "# Changelog\n\n" then there were no new changes 165 if not "".join(new_changes) == "# Changelog\n\n": 166 changelog_path.write_text("".join(new_changes + previous_changelog))
Update project changelog.
169def tag_version(package_path: Pathier): 170 """Add a git tag corresponding to the version number in pyproject.toml.""" 171 if hassle_config.config_exists(): 172 tag_prefix = hassle_config.load_config()["git"]["tag_prefix"] 173 else: 174 hassle_config.warn() 175 tag_prefix = "" 176 version = (package_path / "pyproject.toml").loads()["project"]["version"] 177 os.chdir(package_path) 178 git = Git() 179 git.tag(f"{tag_prefix}{version}")
Add a git tag corresponding to the version number in pyproject.toml.
182def format_files(path: Pathier): 183 """Use `Black` to format file(s).""" 184 try: 185 black.main([str(path)]) 186 except SystemExit: 187 ...
Use Black
to format file(s).
190def on_primary_branch() -> bool: 191 """Returns `False` if repo is not currently on `main` or `master` branch.""" 192 git = Git(True) 193 if git.current_branch not in ["main", "master"]: 194 return False 195 return True
Returns False
if repo is not currently on main
or master
branch.
198def latest_version_is_published(pyproject_path: Pathier) -> bool: 199 """Return `True` if the version number in `pyproject.toml` and the project page on `pypi.org` agree.""" 200 data = pyproject_path.loads() 201 name = data["project"]["name"] 202 version = data["project"]["version"] 203 pypi_url = f"https://pypi.org/project/{name}" 204 response = requests.get(pypi_url) 205 if response.status_code != 200: 206 raise RuntimeError(f"{pypi_url} returned status code {response.status_code} :/") 207 soup = BeautifulSoup(response.text, "html.parser") 208 header = soup.find("h1", class_="package-header__name").text.strip() 209 pypi_version = header[header.rfind(" ") + 1 :] 210 return version == pypi_version
Return True
if the version number in pyproject.toml
and the project page on pypi.org
agree.