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

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

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

    149def check_pypi_for_name_cli():
    150    parser = argparse.ArgumentParser()
    151    parser.add_argument("name", type=str)
    152    args = parser.parse_args()
    153    if check_pypi_for_name(args.name):
    154        print(f"{args.name} is already taken.")
    155    else:
    156        print(f"{args.name} is available.")

def create_pyproject_file(targetdir: pathlib.Path, args: argparse.Namespace): View Source

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

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

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

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

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

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

    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)