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