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 111 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 129 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 144 145 146def check_pypi_for_name_cli(): 147 parser = argparse.ArgumentParser() 148 parser.add_argument("name", type=str) 149 args = parser.parse_args() 150 if check_pypi_for_name(args.name): 151 print(f"{args.name} is already taken.") 152 else: 153 print(f"{args.name} is available.") 154 155 156def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace): 157 """Create pyproject.toml in ./{project_name} from args, 158 pyproject_template, and hassle_config.""" 159 pyproject = (root / "pyproject_template.toml").loads() 160 if not hassle_config.config_exists(): 161 hassle_config.warn() 162 if not get_answer("Continue creating new package with blank config?"): 163 raise Exception("Aborting new package creation") 164 else: 165 print("Creating blank hassle_config.toml...") 166 hassle_config.create_config() 167 config = hassle_config.load_config() 168 pyproject["project"]["name"] = args.name 169 pyproject["project"]["authors"] = config["authors"] 170 pyproject["project"]["description"] = args.description 171 pyproject["project"]["dependencies"] = args.dependencies 172 pyproject["project"]["keywords"] = args.keywords 173 if args.operating_system: 174 pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join( 175 args.operating_system 176 ) 177 if args.no_license: 178 pyproject["project"]["classifiers"].pop(1) 179 for field in config["project_urls"]: 180 pyproject["project"]["urls"][field] = config["project_urls"][field].replace( 181 "$name", args.name 182 ) 183 if args.add_script: 184 pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main" 185 (targetdir / "pyproject.toml").dumps(pyproject) 186 187 188def create_source_files(srcdir: Pathier, filelist: list[str]): 189 """Generate empty source files in ./{package_name}/src/{package_name}/""" 190 srcdir.mkdir(parents=True, exist_ok=True) 191 for file in filelist: 192 (srcdir / file).touch() 193 194 195def create_readme(targetdir: Pathier, args: argparse.Namespace): 196 """Create README.md in ./{package_name} 197 from readme_template and args.""" 198 readme = (root / "README_template.md").read_text() 199 readme = readme.replace("$name", args.name).replace( 200 "$description", args.description 201 ) 202 (targetdir / "README.md").write_text(readme) 203 204 205def create_license(targetdir: Pathier): 206 """Add MIT license file to ./{package_name} .""" 207 license_template = (root / "license_template.txt").read_text() 208 license_template = license_template.replace("$year", str(datetime.now().year)) 209 (targetdir / "LICENSE.txt").write_text(license_template) 210 211 212def create_gitignore(targetdir: Pathier): 213 """Add .gitignore to ./{package_name}""" 214 (root / ".gitignore_template").copy(targetdir / ".gitignore", True) 215 216 217def create_vscode_settings(targetdir: Pathier): 218 """Add settings.json to ./.vscode""" 219 vsdir = targetdir / ".vscode" 220 vsdir.mkdir(parents=True, exist_ok=True) 221 (root / ".vscode_template").copy(vsdir / "settings.json", True) 222 223 224def main(args: argparse.Namespace = None): 225 if not args: 226 args = get_args() 227 if not args.not_package: 228 try: 229 if check_pypi_for_name(args.name): 230 print(f"{args.name} already exists on pypi.org") 231 if not get_answer("Continue anyway?"): 232 sys.exit(0) 233 except Exception as e: 234 print(e) 235 print( 236 f"Couldn't verify that {args.name} doesn't already exist on pypi.org ." 237 ) 238 if not get_answer("Continue anyway?"): 239 sys.exit(0) 240 try: 241 targetdir: Pathier = Pathier.cwd() / args.name 242 try: 243 targetdir.mkdir(parents=True, exist_ok=False) 244 except: 245 print(f"{targetdir} already exists.") 246 if not get_answer("Overwrite?"): 247 sys.exit(0) 248 if not args.not_package: 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 os.system("git init -b main") 263 264 except Exception as e: 265 if not "Aborting new package creation" in str(e): 266 print(e) 267 if get_answer("Delete created files?"): 268 targetdir.delete() 269 270 271if __name__ == "__main__": 272 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 112 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 130 already exists on pypi.org . 131 Returns True if package name exists. 132 Only checks the first page of results.""" 133 url = f"https://pypi.org/search/?q={package_name.lower()}" 134 response = requests.get(url) 135 if response.status_code != 200: 136 raise RuntimeError( 137 f"Error: pypi.org returned status code: {response.status_code}" 138 ) 139 soup = BeautifulSoup(response.text, "html.parser") 140 pypi_packages = [ 141 span.text.lower() 142 for span in soup.find_all("span", class_="package-snippet__name") 143 ] 144 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):
157def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace): 158 """Create pyproject.toml in ./{project_name} from args, 159 pyproject_template, and hassle_config.""" 160 pyproject = (root / "pyproject_template.toml").loads() 161 if not hassle_config.config_exists(): 162 hassle_config.warn() 163 if not get_answer("Continue creating new package with blank config?"): 164 raise Exception("Aborting new package creation") 165 else: 166 print("Creating blank hassle_config.toml...") 167 hassle_config.create_config() 168 config = hassle_config.load_config() 169 pyproject["project"]["name"] = args.name 170 pyproject["project"]["authors"] = config["authors"] 171 pyproject["project"]["description"] = args.description 172 pyproject["project"]["dependencies"] = args.dependencies 173 pyproject["project"]["keywords"] = args.keywords 174 if args.operating_system: 175 pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join( 176 args.operating_system 177 ) 178 if args.no_license: 179 pyproject["project"]["classifiers"].pop(1) 180 for field in config["project_urls"]: 181 pyproject["project"]["urls"][field] = config["project_urls"][field].replace( 182 "$name", args.name 183 ) 184 if args.add_script: 185 pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main" 186 (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]):
189def create_source_files(srcdir: Pathier, filelist: list[str]): 190 """Generate empty source files in ./{package_name}/src/{package_name}/""" 191 srcdir.mkdir(parents=True, exist_ok=True) 192 for file in filelist: 193 (srcdir / file).touch()
Generate empty source files in ./{package_name}/src/{package_name}/
def
create_readme(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
196def create_readme(targetdir: Pathier, args: argparse.Namespace): 197 """Create README.md in ./{package_name} 198 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)
Create README.md in ./{package_name} from readme_template and args.
def
create_license(targetdir: pathier.pathier.Pathier):
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)
Add MIT license file to ./{package_name} .
def
create_gitignore(targetdir: pathier.pathier.Pathier):
213def create_gitignore(targetdir: Pathier): 214 """Add .gitignore to ./{package_name}""" 215 (root / ".gitignore_template").copy(targetdir / ".gitignore", True)
Add .gitignore to ./{package_name}
def
create_vscode_settings(targetdir: pathier.pathier.Pathier):
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)
Add settings.json to ./.vscode
def
main(args: argparse.Namespace = None):
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 if not args.not_package: 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 os.system("git init -b main") 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()