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