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 (targetdir / "pyproject.toml").dumps(pyproject) 184 185 186def create_source_files(srcdir: Pathier, filelist: list[str]): 187 """Generate empty source files in `./{package_name}/src/{package_name}/`""" 188 srcdir.mkdir(parents=True, exist_ok=True) 189 for file in filelist: 190 (srcdir / file).touch() 191 192 193def create_readme(targetdir: Pathier, args: argparse.Namespace): 194 """Create `README.md` in `./{package_name}` from readme_template and args.""" 195 readme = (root / "README_template.md").read_text() 196 readme = readme.replace("$name", args.name).replace( 197 "$description", args.description 198 ) 199 (targetdir / "README.md").write_text(readme) 200 201 202def create_license(targetdir: Pathier): 203 """Add MIT license file to `./{package_name}`.""" 204 license_template = (root / "license_template.txt").read_text() 205 license_template = license_template.replace("$year", str(datetime.now().year)) 206 (targetdir / "LICENSE.txt").write_text(license_template) 207 208 209def create_gitignore(targetdir: Pathier): 210 """Add `.gitignore` to `./{package_name}`""" 211 (root / ".gitignore_template").copy(targetdir / ".gitignore", True) 212 213 214def create_vscode_settings(targetdir: Pathier): 215 """Add `settings.json` to `./.vscode`""" 216 vsdir = targetdir / ".vscode" 217 vsdir.mkdir(parents=True, exist_ok=True) 218 (root / ".vscode_template").copy(vsdir / "settings.json", True) 219 220 221def main(args: argparse.Namespace = None): 222 if not args: 223 args = get_args() 224 if not args.not_package: 225 try: 226 if check_pypi_for_name(args.name): 227 print(f"{args.name} already exists on pypi.org") 228 if not get_answer("Continue anyway?"): 229 sys.exit(0) 230 except Exception as e: 231 print(e) 232 print( 233 f"Couldn't verify that {args.name} doesn't already exist on pypi.org ." 234 ) 235 if not get_answer("Continue anyway?"): 236 sys.exit(0) 237 try: 238 targetdir: Pathier = Pathier.cwd() / args.name 239 try: 240 targetdir.mkdir(parents=True, exist_ok=False) 241 except: 242 print(f"{targetdir} already exists.") 243 if not get_answer("Overwrite?"): 244 sys.exit(0) 245 if not args.not_package: 246 create_pyproject_file(targetdir, args) 247 create_source_files( 248 targetdir if args.not_package else (targetdir / "src" / args.name), 249 args.source_files[1:] if args.not_package else args.source_files, 250 ) 251 create_readme(targetdir, args) 252 if not args.not_package: 253 generate_test_files(targetdir) 254 create_vscode_settings(targetdir) 255 create_gitignore(targetdir) 256 if not args.no_license: 257 create_license(targetdir) 258 os.chdir(targetdir) 259 git.new_repo() 260 261 except Exception as e: 262 if not "Aborting new package creation" in str(e): 263 print(e) 264 if get_answer("Delete created files?"): 265 targetdir.delete() 266 267 268if __name__ == "__main__": 269 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 (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]):
187def create_source_files(srcdir: Pathier, filelist: list[str]): 188 """Generate empty source files in `./{package_name}/src/{package_name}/`""" 189 srcdir.mkdir(parents=True, exist_ok=True) 190 for file in filelist: 191 (srcdir / file).touch()
Generate empty source files in ./{package_name}/src/{package_name}/
def
create_readme(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
194def create_readme(targetdir: Pathier, args: argparse.Namespace): 195 """Create `README.md` in `./{package_name}` from readme_template and args.""" 196 readme = (root / "README_template.md").read_text() 197 readme = readme.replace("$name", args.name).replace( 198 "$description", args.description 199 ) 200 (targetdir / "README.md").write_text(readme)
Create README.md
in ./{package_name}
from readme_template and args.
def
create_license(targetdir: pathier.pathier.Pathier):
203def create_license(targetdir: Pathier): 204 """Add MIT license file to `./{package_name}`.""" 205 license_template = (root / "license_template.txt").read_text() 206 license_template = license_template.replace("$year", str(datetime.now().year)) 207 (targetdir / "LICENSE.txt").write_text(license_template)
Add MIT license file to ./{package_name}
.
def
create_gitignore(targetdir: pathier.pathier.Pathier):
210def create_gitignore(targetdir: Pathier): 211 """Add `.gitignore` to `./{package_name}`""" 212 (root / ".gitignore_template").copy(targetdir / ".gitignore", True)
Add .gitignore
to ./{package_name}
def
create_vscode_settings(targetdir: pathier.pathier.Pathier):
215def create_vscode_settings(targetdir: Pathier): 216 """Add `settings.json` to `./.vscode`""" 217 vsdir = targetdir / ".vscode" 218 vsdir.mkdir(parents=True, exist_ok=True) 219 (root / ".vscode_template").copy(vsdir / "settings.json", True)
Add settings.json
to ./.vscode
def
main(args: argparse.Namespace = None):
222def main(args: argparse.Namespace = None): 223 if not args: 224 args = get_args() 225 if not args.not_package: 226 try: 227 if check_pypi_for_name(args.name): 228 print(f"{args.name} already exists on pypi.org") 229 if not get_answer("Continue anyway?"): 230 sys.exit(0) 231 except Exception as e: 232 print(e) 233 print( 234 f"Couldn't verify that {args.name} doesn't already exist on pypi.org ." 235 ) 236 if not get_answer("Continue anyway?"): 237 sys.exit(0) 238 try: 239 targetdir: Pathier = Pathier.cwd() / args.name 240 try: 241 targetdir.mkdir(parents=True, exist_ok=False) 242 except: 243 print(f"{targetdir} already exists.") 244 if not get_answer("Overwrite?"): 245 sys.exit(0) 246 if not args.not_package: 247 create_pyproject_file(targetdir, args) 248 create_source_files( 249 targetdir if args.not_package else (targetdir / "src" / args.name), 250 args.source_files[1:] if args.not_package else args.source_files, 251 ) 252 create_readme(targetdir, args) 253 if not args.not_package: 254 generate_test_files(targetdir) 255 create_vscode_settings(targetdir) 256 create_gitignore(targetdir) 257 if not args.no_license: 258 create_license(targetdir) 259 os.chdir(targetdir) 260 git.new_repo() 261 262 except Exception as e: 263 if not "Aborting new package creation" in str(e): 264 print(e) 265 if get_answer("Delete created files?"): 266 targetdir.delete()