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 args = parser.parse_args() 99 args.source_files.extend(["__init__.py", f"{args.name}.py"]) 100 101 return args 102 103 104def get_answer(question: str) -> bool: 105 """Repeatedly ask the user a yes/no question 106 until a 'y' or a 'n' is received.""" 107 ans = "" 108 question = question.strip() 109 if "?" not in question: 110 question += "?" 111 question += " (y/n): " 112 while ans not in ["y", "yes", "no", "n"]: 113 ans = input(question).strip().lower() 114 if ans in ["y", "yes"]: 115 return True 116 elif ans in ["n", "no"]: 117 return False 118 else: 119 print("Invalid answer.") 120 121 122def check_pypi_for_name(package_name: str) -> bool: 123 """Check if a package with package_name 124 already exists on pypi.org . 125 Returns True if package name exists. 126 Only checks the first page of results.""" 127 url = f"https://pypi.org/search/?q={package_name.lower()}" 128 response = requests.get(url) 129 if response.status_code != 200: 130 raise RuntimeError( 131 f"Error: pypi.org returned status code: {response.status_code}" 132 ) 133 soup = BeautifulSoup(response.text, "html.parser") 134 pypi_packages = [ 135 span.text.lower() 136 for span in soup.find_all("span", class_="package-snippet__name") 137 ] 138 return package_name in pypi_packages 139 140 141def check_pypi_for_name_cli(): 142 parser = argparse.ArgumentParser() 143 parser.add_argument("name", type=str) 144 args = parser.parse_args() 145 if check_pypi_for_name(args.name): 146 print(f"{args.name} is already taken.") 147 else: 148 print(f"{args.name} is available.") 149 150 151def create_pyproject_file(targetdir: Path, args: argparse.Namespace): 152 """Create pyproject.toml in ./{project_name} from args, 153 pyproject_template, and hassle_config.""" 154 pyproject = tomlkit.loads((root / "pyproject_template.toml").read_text()) 155 if not hassle_config.config_exists(): 156 hassle_config.warn() 157 if not get_answer("Continue creating new package with blank config?"): 158 raise Exception("Aborting new package creation") 159 else: 160 print("Creating blank hassle_config.toml...") 161 hassle_config.create_config() 162 config = hassle_config.load_config() 163 pyproject["project"]["name"] = args.name 164 pyproject["project"]["authors"] = config["authors"] 165 pyproject["project"]["description"] = args.description 166 pyproject["project"]["dependencies"] = args.dependencies 167 pyproject["project"]["keywords"] = args.keywords 168 if args.operating_system: 169 pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join( 170 args.operating_system 171 ) 172 if args.no_license: 173 pyproject["project"]["classifiers"].pop(1) 174 for field in config["project_urls"]: 175 pyproject["project"]["urls"][field] = config["project_urls"][field].replace( 176 "$name", args.name 177 ) 178 if args.add_script: 179 pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main" 180 (targetdir / "pyproject.toml").write_text(tomlkit.dumps(pyproject)) 181 182 183def create_source_files(srcdir: Path, filelist: list[str]): 184 """Generate empty source files in ./{package_name}/src/{package_name}/""" 185 srcdir.mkdir(parents=True, exist_ok=True) 186 for file in filelist: 187 (srcdir / file).touch() 188 189 190def create_readme(targetdir: Path, args: argparse.Namespace): 191 """Create README.md in ./{package_name} 192 from readme_template and args.""" 193 readme = (root / "README_template.md").read_text() 194 readme = readme.replace("$name", args.name).replace( 195 "$description", args.description 196 ) 197 (targetdir / "README.md").write_text(readme) 198 199 200def create_license(targetdir: Path): 201 """Add MIT license file to ./{package_name} .""" 202 license_template = (root / "license_template.txt").read_text() 203 license_template = license_template.replace("$year", str(datetime.now().year)) 204 (targetdir / "LICENSE.txt").write_text(license_template) 205 206 207def create_gitignore(targetdir: Path): 208 """Add .gitignore to ./{package_name}""" 209 shutil.copy(root / ".gitignore_template", targetdir / ".gitignore") 210 211 212def create_vscode_settings(targetdir: Path): 213 """Add settings.json to ./.vscode""" 214 vsdir = targetdir / ".vscode" 215 vsdir.mkdir(parents=True, exist_ok=True) 216 shutil.copy(root / ".vscode_template", vsdir / "settings.json") 217 218 219def main(args: argparse.Namespace = None): 220 if not args: 221 args = get_args() 222 try: 223 if check_pypi_for_name(args.name): 224 print(f"{args.name} already exists on pypi.org") 225 if not get_answer("Continue anyway?"): 226 sys.exit(0) 227 except Exception as e: 228 print(e) 229 print(f"Couldn't verify that {args.name} doesn't already exist on pypi.org .") 230 if not get_answer("Continue anyway?"): 231 sys.exit(0) 232 try: 233 targetdir = Path.cwd() / args.name 234 try: 235 targetdir.mkdir(parents=True, exist_ok=False) 236 except: 237 print(f"{targetdir} already exists.") 238 if not get_answer("Overwrite?"): 239 sys.exit(0) 240 create_pyproject_file(targetdir, args) 241 create_source_files((targetdir / "src" / args.name), args.source_files) 242 create_readme(targetdir, args) 243 generate_test_files(targetdir) 244 create_gitignore(targetdir) 245 create_vscode_settings(targetdir) 246 if not args.no_license: 247 create_license(targetdir) 248 os.chdir(targetdir) 249 os.system("git init -b main") 250 except Exception as e: 251 if not "Aborting new package creation" in str(e): 252 print(e) 253 if get_answer("Delete created files?"): 254 shutil.rmtree(targetdir) 255 256 257if __name__ == "__main__": 258 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 args = parser.parse_args() 100 args.source_files.extend(["__init__.py", f"{args.name}.py"]) 101 102 return args
def
get_answer(question: str) -> bool:
105def get_answer(question: str) -> bool: 106 """Repeatedly ask the user a yes/no question 107 until a 'y' or a 'n' is received.""" 108 ans = "" 109 question = question.strip() 110 if "?" not in question: 111 question += "?" 112 question += " (y/n): " 113 while ans not in ["y", "yes", "no", "n"]: 114 ans = input(question).strip().lower() 115 if ans in ["y", "yes"]: 116 return True 117 elif ans in ["n", "no"]: 118 return False 119 else: 120 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:
123def check_pypi_for_name(package_name: str) -> bool: 124 """Check if a package with package_name 125 already exists on pypi.org . 126 Returns True if package name exists. 127 Only checks the first page of results.""" 128 url = f"https://pypi.org/search/?q={package_name.lower()}" 129 response = requests.get(url) 130 if response.status_code != 200: 131 raise RuntimeError( 132 f"Error: pypi.org returned status code: {response.status_code}" 133 ) 134 soup = BeautifulSoup(response.text, "html.parser") 135 pypi_packages = [ 136 span.text.lower() 137 for span in soup.find_all("span", class_="package-snippet__name") 138 ] 139 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):
152def create_pyproject_file(targetdir: Path, args: argparse.Namespace): 153 """Create pyproject.toml in ./{project_name} from args, 154 pyproject_template, and hassle_config.""" 155 pyproject = tomlkit.loads((root / "pyproject_template.toml").read_text()) 156 if not hassle_config.config_exists(): 157 hassle_config.warn() 158 if not get_answer("Continue creating new package with blank config?"): 159 raise Exception("Aborting new package creation") 160 else: 161 print("Creating blank hassle_config.toml...") 162 hassle_config.create_config() 163 config = hassle_config.load_config() 164 pyproject["project"]["name"] = args.name 165 pyproject["project"]["authors"] = config["authors"] 166 pyproject["project"]["description"] = args.description 167 pyproject["project"]["dependencies"] = args.dependencies 168 pyproject["project"]["keywords"] = args.keywords 169 if args.operating_system: 170 pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join( 171 args.operating_system 172 ) 173 if args.no_license: 174 pyproject["project"]["classifiers"].pop(1) 175 for field in config["project_urls"]: 176 pyproject["project"]["urls"][field] = config["project_urls"][field].replace( 177 "$name", args.name 178 ) 179 if args.add_script: 180 pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main" 181 (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]):
184def create_source_files(srcdir: Path, filelist: list[str]): 185 """Generate empty source files in ./{package_name}/src/{package_name}/""" 186 srcdir.mkdir(parents=True, exist_ok=True) 187 for file in filelist: 188 (srcdir / file).touch()
Generate empty source files in ./{package_name}/src/{package_name}/
def
create_readme(targetdir: pathlib.Path, args: argparse.Namespace):
191def create_readme(targetdir: Path, args: argparse.Namespace): 192 """Create README.md in ./{package_name} 193 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)
Create README.md in ./{package_name} from readme_template and args.
def
create_license(targetdir: pathlib.Path):
201def create_license(targetdir: Path): 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)
Add MIT license file to ./{package_name} .
def
create_gitignore(targetdir: pathlib.Path):
208def create_gitignore(targetdir: Path): 209 """Add .gitignore to ./{package_name}""" 210 shutil.copy(root / ".gitignore_template", targetdir / ".gitignore")
Add .gitignore to ./{package_name}
def
create_vscode_settings(targetdir: pathlib.Path):
213def create_vscode_settings(targetdir: Path): 214 """Add settings.json to ./.vscode""" 215 vsdir = targetdir / ".vscode" 216 vsdir.mkdir(parents=True, exist_ok=True) 217 shutil.copy(root / ".vscode_template", vsdir / "settings.json")
Add settings.json to ./.vscode
def
main(args: argparse.Namespace = None):
220def main(args: argparse.Namespace = None): 221 if not args: 222 args = get_args() 223 try: 224 if check_pypi_for_name(args.name): 225 print(f"{args.name} already exists on pypi.org") 226 if not get_answer("Continue anyway?"): 227 sys.exit(0) 228 except Exception as e: 229 print(e) 230 print(f"Couldn't verify that {args.name} doesn't already exist on pypi.org .") 231 if not get_answer("Continue anyway?"): 232 sys.exit(0) 233 try: 234 targetdir = Path.cwd() / args.name 235 try: 236 targetdir.mkdir(parents=True, exist_ok=False) 237 except: 238 print(f"{targetdir} already exists.") 239 if not get_answer("Overwrite?"): 240 sys.exit(0) 241 create_pyproject_file(targetdir, args) 242 create_source_files((targetdir / "src" / args.name), args.source_files) 243 create_readme(targetdir, args) 244 generate_test_files(targetdir) 245 create_gitignore(targetdir) 246 create_vscode_settings(targetdir) 247 if not args.no_license: 248 create_license(targetdir) 249 os.chdir(targetdir) 250 os.system("git init -b main") 251 except Exception as e: 252 if not "Aborting new package creation" in str(e): 253 print(e) 254 if get_answer("Delete created files?"): 255 shutil.rmtree(targetdir)