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():
142def check_pypi_for_name_cli():
143    parser = argparse.ArgumentParser()
144    parser.add_argument("name", type=str)
145    args = parser.parse_args()
146    if check_pypi_for_name(args.name):
147        print(f"{args.name} is already taken.")
148    else:
149        print(f"{args.name} is available.")
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)