hassle.new_project
1import argparse 2import os 3import sys 4from datetime import datetime 5 6import requests 7from bs4 import BeautifulSoup 8from gitbetter import Git 9from pathier import Pathier 10 11import hassle.hassle_config as hassle_config 12from hassle.generate_tests import generate_test_files 13 14root = Pathier(__file__).parent 15 16 17def get_args() -> argparse.Namespace: 18 parser = argparse.ArgumentParser() 19 20 parser.add_argument( 21 "name", 22 type=str, 23 help=""" Name of the package to create in the current working directory. """, 24 ) 25 26 parser.add_argument( 27 "-s", 28 "--source_files", 29 nargs="*", 30 type=str, 31 default=[], 32 help=""" List of additional source files to create in addition to the default 33 __init__.py and {name}.py files.""", 34 ) 35 36 parser.add_argument( 37 "-d", 38 "--description", 39 type=str, 40 default="", 41 help=""" The package description to be added to the pyproject.toml file. """, 42 ) 43 44 parser.add_argument( 45 "-dp", 46 "--dependencies", 47 nargs="*", 48 type=str, 49 default=[], 50 help=""" List of dependencies to add to pyproject.toml. 51 Note: hassle.py will automatically scan your project for 3rd party 52 imports and update pyproject.toml. This switch is largely useful 53 for adding dependencies your project might need, but doesn't 54 directly import in any source files, 55 like an os.system() call that invokes a 3rd party cli.""", 56 ) 57 58 parser.add_argument( 59 "-k", 60 "--keywords", 61 nargs="*", 62 type=str, 63 default=[], 64 help=""" List of keywords to be added to the keywords field in pyproject.toml. """, 65 ) 66 67 parser.add_argument( 68 "-as", 69 "--add_script", 70 action="store_true", 71 help=""" Add section to pyproject.toml declaring the package 72 should be installed with command line scripts added. 73 The default is '{name} = "{name}.{name}:main". 74 You will need to manually change this field.""", 75 ) 76 77 parser.add_argument( 78 "-nl", 79 "--no_license", 80 action="store_true", 81 help=""" By default, projects are created with an MIT license. 82 Set this flag to avoid adding a license if you want to configure licensing 83 at another time.""", 84 ) 85 86 parser.add_argument( 87 "-os", 88 "--operating_system", 89 type=str, 90 default=None, 91 nargs="*", 92 help=""" List of operating systems this package will be compatible with. 93 The default is OS Independent. 94 This only affects the 'classifiers' field of pyproject.toml .""", 95 ) 96 97 parser.add_argument( 98 "-np", 99 "--not_package", 100 action="store_true", 101 help=""" Put source files in top level directory and delete tests folder. """, 102 ) 103 104 args = parser.parse_args() 105 args.source_files.extend(["__init__.py", f"{args.name}.py"]) 106 107 return args 108 109 110def get_answer(question: str) -> bool: 111 """Repeatedly ask the user a yes/no question until a 'y' or a 'n' is received.""" 112 ans = "" 113 question = question.strip() 114 if "?" not in question: 115 question += "?" 116 question += " (y/n): " 117 while ans not in ["y", "yes", "no", "n"]: 118 ans = input(question).strip().lower() 119 if ans in ["y", "yes"]: 120 return True 121 elif ans in ["n", "no"]: 122 return False 123 else: 124 print("Invalid answer.") 125 126 127def check_pypi_for_name(package_name: str) -> bool: 128 """Check if a package with package_name already exists on `pypi.org`. 129 Returns `True` if package name exists. 130 Only checks the first page of results.""" 131 url = f"https://pypi.org/search/?q={package_name.lower()}" 132 response = requests.get(url) 133 if response.status_code != 200: 134 raise RuntimeError( 135 f"Error: pypi.org returned status code: {response.status_code}" 136 ) 137 soup = BeautifulSoup(response.text, "html.parser") 138 pypi_packages = [ 139 span.text.lower() 140 for span in soup.find_all("span", class_="package-snippet__name") 141 ] 142 return package_name in pypi_packages 143 144 145def check_pypi_for_name_cli(): 146 parser = argparse.ArgumentParser() 147 parser.add_argument("name", type=str) 148 args = parser.parse_args() 149 if check_pypi_for_name(args.name): 150 print(f"{args.name} is already taken.") 151 else: 152 print(f"{args.name} is available.") 153 154 155def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace): 156 """Create `pyproject.toml` in `./{project_name}` from args, pyproject_template, and hassle_config.""" 157 pyproject = (root / "pyproject_template.toml").loads() 158 if not hassle_config.config_exists(): 159 hassle_config.warn() 160 if not get_answer("Continue creating new package with blank config?"): 161 raise Exception("Aborting new package creation") 162 else: 163 print("Creating blank hassle_config.toml...") 164 hassle_config.create_config() 165 config = hassle_config.load_config() 166 pyproject["project"]["name"] = args.name 167 pyproject["project"]["authors"] = config["authors"] 168 pyproject["project"]["description"] = args.description 169 pyproject["project"]["dependencies"] = args.dependencies 170 pyproject["project"]["keywords"] = args.keywords 171 if args.operating_system: 172 pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join( 173 args.operating_system 174 ) 175 if args.no_license: 176 pyproject["project"]["classifiers"].pop(1) 177 for field in config["project_urls"]: 178 pyproject["project"]["urls"][field] = config["project_urls"][field].replace( 179 "$name", args.name 180 ) 181 if args.add_script: 182 pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main" 183 if args.not_package: 184 for item in ["build-system", "tool", "project.scripts"]: 185 if item in pyproject: 186 pyproject.pop(item) 187 (targetdir / "pyproject.toml").dumps(pyproject) 188 189 190def create_source_files(srcdir: Pathier, filelist: list[str]): 191 """Generate empty source files in `./{package_name}/src/{package_name}/`""" 192 srcdir.mkdir(parents=True, exist_ok=True) 193 for file in filelist: 194 (srcdir / file).touch() 195 196 197def create_readme(targetdir: Pathier, args: argparse.Namespace): 198 """Create `README.md` in `./{package_name}` from readme_template and args.""" 199 readme = (root / "README_template.md").read_text() 200 readme = readme.replace("$name", args.name).replace( 201 "$description", args.description 202 ) 203 (targetdir / "README.md").write_text(readme) 204 205 206def create_license(targetdir: Pathier): 207 """Add MIT license file to `./{package_name}`.""" 208 license_template = (root / "license_template.txt").read_text() 209 license_template = license_template.replace("$year", str(datetime.now().year)) 210 (targetdir / "LICENSE.txt").write_text(license_template) 211 212 213def create_gitignore(targetdir: Pathier): 214 """Add `.gitignore` to `./{package_name}`""" 215 (root / ".gitignore_template").copy(targetdir / ".gitignore", True) 216 217 218def create_vscode_settings(targetdir: Pathier): 219 """Add `settings.json` to `./.vscode`""" 220 vsdir = targetdir / ".vscode" 221 vsdir.mkdir(parents=True, exist_ok=True) 222 (root / ".vscode_template").copy(vsdir / "settings.json", True) 223 224 225def main(args: argparse.Namespace = None): 226 if not args: 227 args = get_args() 228 if not args.not_package: 229 try: 230 if check_pypi_for_name(args.name): 231 print(f"{args.name} already exists on pypi.org") 232 if not get_answer("Continue anyway?"): 233 sys.exit(0) 234 except Exception as e: 235 print(e) 236 print( 237 f"Couldn't verify that {args.name} doesn't already exist on pypi.org ." 238 ) 239 if not get_answer("Continue anyway?"): 240 sys.exit(0) 241 try: 242 targetdir: Pathier = Pathier.cwd() / args.name 243 try: 244 targetdir.mkdir(parents=True, exist_ok=False) 245 except: 246 print(f"{targetdir} already exists.") 247 if not get_answer("Overwrite?"): 248 sys.exit(0) 249 create_pyproject_file(targetdir, args) 250 create_source_files( 251 targetdir if args.not_package else (targetdir / "src" / args.name), 252 args.source_files[1:] if args.not_package else args.source_files, 253 ) 254 create_readme(targetdir, args) 255 if not args.not_package: 256 generate_test_files(targetdir) 257 create_vscode_settings(targetdir) 258 create_gitignore(targetdir) 259 if not args.no_license: 260 create_license(targetdir) 261 os.chdir(targetdir) 262 git = Git() 263 git.new_repo() 264 265 except Exception as e: 266 if not "Aborting new package creation" in str(e): 267 print(e) 268 if get_answer("Delete created files?"): 269 targetdir.delete() 270 271 272if __name__ == "__main__": 273 main(get_args())
def
get_args() -> argparse.Namespace:
18def get_args() -> argparse.Namespace: 19 parser = argparse.ArgumentParser() 20 21 parser.add_argument( 22 "name", 23 type=str, 24 help=""" Name of the package to create in the current working directory. """, 25 ) 26 27 parser.add_argument( 28 "-s", 29 "--source_files", 30 nargs="*", 31 type=str, 32 default=[], 33 help=""" List of additional source files to create in addition to the default 34 __init__.py and {name}.py files.""", 35 ) 36 37 parser.add_argument( 38 "-d", 39 "--description", 40 type=str, 41 default="", 42 help=""" The package description to be added to the pyproject.toml file. """, 43 ) 44 45 parser.add_argument( 46 "-dp", 47 "--dependencies", 48 nargs="*", 49 type=str, 50 default=[], 51 help=""" List of dependencies to add to pyproject.toml. 52 Note: hassle.py will automatically scan your project for 3rd party 53 imports and update pyproject.toml. This switch is largely useful 54 for adding dependencies your project might need, but doesn't 55 directly import in any source files, 56 like an os.system() call that invokes a 3rd party cli.""", 57 ) 58 59 parser.add_argument( 60 "-k", 61 "--keywords", 62 nargs="*", 63 type=str, 64 default=[], 65 help=""" List of keywords to be added to the keywords field in pyproject.toml. """, 66 ) 67 68 parser.add_argument( 69 "-as", 70 "--add_script", 71 action="store_true", 72 help=""" Add section to pyproject.toml declaring the package 73 should be installed with command line scripts added. 74 The default is '{name} = "{name}.{name}:main". 75 You will need to manually change this field.""", 76 ) 77 78 parser.add_argument( 79 "-nl", 80 "--no_license", 81 action="store_true", 82 help=""" By default, projects are created with an MIT license. 83 Set this flag to avoid adding a license if you want to configure licensing 84 at another time.""", 85 ) 86 87 parser.add_argument( 88 "-os", 89 "--operating_system", 90 type=str, 91 default=None, 92 nargs="*", 93 help=""" List of operating systems this package will be compatible with. 94 The default is OS Independent. 95 This only affects the 'classifiers' field of pyproject.toml .""", 96 ) 97 98 parser.add_argument( 99 "-np", 100 "--not_package", 101 action="store_true", 102 help=""" Put source files in top level directory and delete tests folder. """, 103 ) 104 105 args = parser.parse_args() 106 args.source_files.extend(["__init__.py", f"{args.name}.py"]) 107 108 return args
def
get_answer(question: str) -> bool:
111def get_answer(question: str) -> bool: 112 """Repeatedly ask the user a yes/no question until a 'y' or a 'n' is received.""" 113 ans = "" 114 question = question.strip() 115 if "?" not in question: 116 question += "?" 117 question += " (y/n): " 118 while ans not in ["y", "yes", "no", "n"]: 119 ans = input(question).strip().lower() 120 if ans in ["y", "yes"]: 121 return True 122 elif ans in ["n", "no"]: 123 return False 124 else: 125 print("Invalid answer.")
Repeatedly ask the user a yes/no question until a 'y' or a 'n' is received.
def
check_pypi_for_name(package_name: str) -> bool:
128def check_pypi_for_name(package_name: str) -> bool: 129 """Check if a package with package_name already exists on `pypi.org`. 130 Returns `True` if package name exists. 131 Only checks the first page of results.""" 132 url = f"https://pypi.org/search/?q={package_name.lower()}" 133 response = requests.get(url) 134 if response.status_code != 200: 135 raise RuntimeError( 136 f"Error: pypi.org returned status code: {response.status_code}" 137 ) 138 soup = BeautifulSoup(response.text, "html.parser") 139 pypi_packages = [ 140 span.text.lower() 141 for span in soup.find_all("span", class_="package-snippet__name") 142 ] 143 return package_name in pypi_packages
Check if a package with package_name already exists on pypi.org
.
Returns True
if package name exists.
Only checks the first page of results.
def
check_pypi_for_name_cli():
def
create_pyproject_file(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
156def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace): 157 """Create `pyproject.toml` in `./{project_name}` from args, pyproject_template, and hassle_config.""" 158 pyproject = (root / "pyproject_template.toml").loads() 159 if not hassle_config.config_exists(): 160 hassle_config.warn() 161 if not get_answer("Continue creating new package with blank config?"): 162 raise Exception("Aborting new package creation") 163 else: 164 print("Creating blank hassle_config.toml...") 165 hassle_config.create_config() 166 config = hassle_config.load_config() 167 pyproject["project"]["name"] = args.name 168 pyproject["project"]["authors"] = config["authors"] 169 pyproject["project"]["description"] = args.description 170 pyproject["project"]["dependencies"] = args.dependencies 171 pyproject["project"]["keywords"] = args.keywords 172 if args.operating_system: 173 pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join( 174 args.operating_system 175 ) 176 if args.no_license: 177 pyproject["project"]["classifiers"].pop(1) 178 for field in config["project_urls"]: 179 pyproject["project"]["urls"][field] = config["project_urls"][field].replace( 180 "$name", args.name 181 ) 182 if args.add_script: 183 pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main" 184 if args.not_package: 185 for item in ["build-system", "tool", "project.scripts"]: 186 if item in pyproject: 187 pyproject.pop(item) 188 (targetdir / "pyproject.toml").dumps(pyproject)
Create pyproject.toml
in ./{project_name}
from args, pyproject_template, and hassle_config.
def
create_source_files(srcdir: pathier.pathier.Pathier, filelist: list[str]):
191def create_source_files(srcdir: Pathier, filelist: list[str]): 192 """Generate empty source files in `./{package_name}/src/{package_name}/`""" 193 srcdir.mkdir(parents=True, exist_ok=True) 194 for file in filelist: 195 (srcdir / file).touch()
Generate empty source files in ./{package_name}/src/{package_name}/
def
create_readme(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
198def create_readme(targetdir: Pathier, args: argparse.Namespace): 199 """Create `README.md` in `./{package_name}` from readme_template and args.""" 200 readme = (root / "README_template.md").read_text() 201 readme = readme.replace("$name", args.name).replace( 202 "$description", args.description 203 ) 204 (targetdir / "README.md").write_text(readme)
Create README.md
in ./{package_name}
from readme_template and args.
def
create_license(targetdir: pathier.pathier.Pathier):
207def create_license(targetdir: Pathier): 208 """Add MIT license file to `./{package_name}`.""" 209 license_template = (root / "license_template.txt").read_text() 210 license_template = license_template.replace("$year", str(datetime.now().year)) 211 (targetdir / "LICENSE.txt").write_text(license_template)
Add MIT license file to ./{package_name}
.
def
create_gitignore(targetdir: pathier.pathier.Pathier):
214def create_gitignore(targetdir: Pathier): 215 """Add `.gitignore` to `./{package_name}`""" 216 (root / ".gitignore_template").copy(targetdir / ".gitignore", True)
Add .gitignore
to ./{package_name}
def
create_vscode_settings(targetdir: pathier.pathier.Pathier):
219def create_vscode_settings(targetdir: Pathier): 220 """Add `settings.json` to `./.vscode`""" 221 vsdir = targetdir / ".vscode" 222 vsdir.mkdir(parents=True, exist_ok=True) 223 (root / ".vscode_template").copy(vsdir / "settings.json", True)
Add settings.json
to ./.vscode
def
main(args: argparse.Namespace = None):
226def main(args: argparse.Namespace = None): 227 if not args: 228 args = get_args() 229 if not args.not_package: 230 try: 231 if check_pypi_for_name(args.name): 232 print(f"{args.name} already exists on pypi.org") 233 if not get_answer("Continue anyway?"): 234 sys.exit(0) 235 except Exception as e: 236 print(e) 237 print( 238 f"Couldn't verify that {args.name} doesn't already exist on pypi.org ." 239 ) 240 if not get_answer("Continue anyway?"): 241 sys.exit(0) 242 try: 243 targetdir: Pathier = Pathier.cwd() / args.name 244 try: 245 targetdir.mkdir(parents=True, exist_ok=False) 246 except: 247 print(f"{targetdir} already exists.") 248 if not get_answer("Overwrite?"): 249 sys.exit(0) 250 create_pyproject_file(targetdir, args) 251 create_source_files( 252 targetdir if args.not_package else (targetdir / "src" / args.name), 253 args.source_files[1:] if args.not_package else args.source_files, 254 ) 255 create_readme(targetdir, args) 256 if not args.not_package: 257 generate_test_files(targetdir) 258 create_vscode_settings(targetdir) 259 create_gitignore(targetdir) 260 if not args.no_license: 261 create_license(targetdir) 262 os.chdir(targetdir) 263 git = Git() 264 git.new_repo() 265 266 except Exception as e: 267 if not "Aborting new package creation" in str(e): 268 print(e) 269 if get_answer("Delete created files?"): 270 targetdir.delete()