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