hassle.new_project API documentation

# hassle.new_project

View Source

      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: View Source

     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: View Source

    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: View Source

    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(): View Source

    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): View Source

    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]): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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)