hassle.new_project API documentation

# hassle.new_project

View Source

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

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

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

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

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

def create_pyproject_file(targetdir: pathier.pathier.Pathier, args: argparse.Namespace): View Source

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

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

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

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

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

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

    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()