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 pathier import Pathier
      9
     10import hassle.hassle_config as hassle_config
     11from hassle.generate_tests import generate_test_files
     12
     13root = Pathier(__file__).parent
     14
     15
     16def get_args() -> argparse.Namespace:
     17    parser = argparse.ArgumentParser()
     18
     19    parser.add_argument(
     20        "name",
     21        type=str,
     22        help=""" Name of the package to create in the current working directory. """,
     23    )
     24
     25    parser.add_argument(
     26        "-s",
     27        "--source_files",
     28        nargs="*",
     29        type=str,
     30        default=[],
     31        help=""" List of additional source files to create in addition to the default
     32        __init__.py and {name}.py files.""",
     33    )
     34
     35    parser.add_argument(
     36        "-d",
     37        "--description",
     38        type=str,
     39        default="",
     40        help=""" The package description to be added to the pyproject.toml file. """,
     41    )
     42
     43    parser.add_argument(
     44        "-dp",
     45        "--dependencies",
     46        nargs="*",
     47        type=str,
     48        default=[],
     49        help=""" List of dependencies to add to pyproject.toml.
     50        Note: hassle.py will automatically scan your project for 3rd party
     51        imports and update pyproject.toml. This switch is largely useful
     52        for adding dependencies your project might need, but doesn't
     53        directly import in any source files,
     54        like an os.system() call that invokes a 3rd party cli.""",
     55    )
     56
     57    parser.add_argument(
     58        "-k",
     59        "--keywords",
     60        nargs="*",
     61        type=str,
     62        default=[],
     63        help=""" List of keywords to be added to the keywords field in pyproject.toml. """,
     64    )
     65
     66    parser.add_argument(
     67        "-as",
     68        "--add_script",
     69        action="store_true",
     70        help=""" Add section to pyproject.toml declaring the package
     71        should be installed with command line scripts added.
     72        The default is '{name} = "{name}.{name}:main".
     73        You will need to manually change this field.""",
     74    )
     75
     76    parser.add_argument(
     77        "-nl",
     78        "--no_license",
     79        action="store_true",
     80        help=""" By default, projects are created with an MIT license.
     81        Set this flag to avoid adding a license if you want to configure licensing
     82        at another time.""",
     83    )
     84
     85    parser.add_argument(
     86        "-os",
     87        "--operating_system",
     88        type=str,
     89        default=None,
     90        nargs="*",
     91        help=""" List of operating systems this package will be compatible with.
     92        The default is OS Independent.
     93        This only affects the 'classifiers' field of pyproject.toml .""",
     94    )
     95
     96    parser.add_argument(
     97        "-np",
     98        "--not_package",
     99        action="store_true",
    100        help=""" Put source files in top level directory and delete tests folder. """,
    101    )
    102
    103    args = parser.parse_args()
    104    args.source_files.extend(["__init__.py", f"{args.name}.py"])
    105
    106    return args
    107
    108
    109def get_answer(question: str) -> bool:
    110    """Repeatedly ask the user a yes/no question
    111    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
    129    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
    144
    145
    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.")
    154
    155
    156def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace):
    157    """Create pyproject.toml in ./{project_name} from args,
    158    pyproject_template, and hassle_config."""
    159    pyproject = (root / "pyproject_template.toml").loads()
    160    if not hassle_config.config_exists():
    161        hassle_config.warn()
    162        if not get_answer("Continue creating new package with blank config?"):
    163            raise Exception("Aborting new package creation")
    164        else:
    165            print("Creating blank hassle_config.toml...")
    166            hassle_config.create_config()
    167    config = hassle_config.load_config()
    168    pyproject["project"]["name"] = args.name
    169    pyproject["project"]["authors"] = config["authors"]
    170    pyproject["project"]["description"] = args.description
    171    pyproject["project"]["dependencies"] = args.dependencies
    172    pyproject["project"]["keywords"] = args.keywords
    173    if args.operating_system:
    174        pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join(
    175            args.operating_system
    176        )
    177    if args.no_license:
    178        pyproject["project"]["classifiers"].pop(1)
    179    for field in config["project_urls"]:
    180        pyproject["project"]["urls"][field] = config["project_urls"][field].replace(
    181            "$name", args.name
    182        )
    183    if args.add_script:
    184        pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main"
    185    (targetdir / "pyproject.toml").dumps(pyproject)
    186
    187
    188def create_source_files(srcdir: Pathier, filelist: list[str]):
    189    """Generate empty source files in ./{package_name}/src/{package_name}/"""
    190    srcdir.mkdir(parents=True, exist_ok=True)
    191    for file in filelist:
    192        (srcdir / file).touch()
    193
    194
    195def create_readme(targetdir: Pathier, args: argparse.Namespace):
    196    """Create README.md in ./{package_name}
    197    from readme_template and args."""
    198    readme = (root / "README_template.md").read_text()
    199    readme = readme.replace("$name", args.name).replace(
    200        "$description", args.description
    201    )
    202    (targetdir / "README.md").write_text(readme)
    203
    204
    205def create_license(targetdir: Pathier):
    206    """Add MIT license file to ./{package_name} ."""
    207    license_template = (root / "license_template.txt").read_text()
    208    license_template = license_template.replace("$year", str(datetime.now().year))
    209    (targetdir / "LICENSE.txt").write_text(license_template)
    210
    211
    212def create_gitignore(targetdir: Pathier):
    213    """Add .gitignore to ./{package_name}"""
    214    (root / ".gitignore_template").copy(targetdir / ".gitignore", True)
    215
    216
    217def create_vscode_settings(targetdir: Pathier):
    218    """Add settings.json to ./.vscode"""
    219    vsdir = targetdir / ".vscode"
    220    vsdir.mkdir(parents=True, exist_ok=True)
    221    (root / ".vscode_template").copy(vsdir / "settings.json", True)
    222
    223
    224def main(args: argparse.Namespace = None):
    225    if not args:
    226        args = get_args()
    227    if not args.not_package:
    228        try:
    229            if check_pypi_for_name(args.name):
    230                print(f"{args.name} already exists on pypi.org")
    231                if not get_answer("Continue anyway?"):
    232                    sys.exit(0)
    233        except Exception as e:
    234            print(e)
    235            print(
    236                f"Couldn't verify that {args.name} doesn't already exist on pypi.org ."
    237            )
    238            if not get_answer("Continue anyway?"):
    239                sys.exit(0)
    240    try:
    241        targetdir: Pathier = Pathier.cwd() / args.name
    242        try:
    243            targetdir.mkdir(parents=True, exist_ok=False)
    244        except:
    245            print(f"{targetdir} already exists.")
    246            if not get_answer("Overwrite?"):
    247                sys.exit(0)
    248        if not args.not_package:
    249            create_pyproject_file(targetdir, args)
    250        create_source_files(
    251            targetdir if args.not_package else (targetdir / "src" / args.name),
    252            args.source_files[1:] if args.not_package else args.source_files,
    253        )
    254        create_readme(targetdir, args)
    255        if not args.not_package:
    256            generate_test_files(targetdir)
    257            create_vscode_settings(targetdir)
    258        create_gitignore(targetdir)
    259        if not args.no_license:
    260            create_license(targetdir)
    261        os.chdir(targetdir)
    262        os.system("git init -b main")
    263
    264    except Exception as e:
    265        if not "Aborting new package creation" in str(e):
    266            print(e)
    267        if get_answer("Delete created files?"):
    268            targetdir.delete()
    269
    270
    271if __name__ == "__main__":
    272    main(get_args())

def get_args() -> argparse.Namespace: View Source

     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

def get_answer(question: str) -> bool: View Source

    110def get_answer(question: str) -> bool:
    111    """Repeatedly ask the user a yes/no question
    112    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
    130    already exists on pypi.org .
    131    Returns True if package name exists.
    132    Only checks the first page of results."""
    133    url = f"https://pypi.org/search/?q={package_name.lower()}"
    134    response = requests.get(url)
    135    if response.status_code != 200:
    136        raise RuntimeError(
    137            f"Error: pypi.org returned status code: {response.status_code}"
    138        )
    139    soup = BeautifulSoup(response.text, "html.parser")
    140    pypi_packages = [
    141        span.text.lower()
    142        for span in soup.find_all("span", class_="package-snippet__name")
    143    ]
    144    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

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

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

    157def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace):
    158    """Create pyproject.toml in ./{project_name} from args,
    159    pyproject_template, and hassle_config."""
    160    pyproject = (root / "pyproject_template.toml").loads()
    161    if not hassle_config.config_exists():
    162        hassle_config.warn()
    163        if not get_answer("Continue creating new package with blank config?"):
    164            raise Exception("Aborting new package creation")
    165        else:
    166            print("Creating blank hassle_config.toml...")
    167            hassle_config.create_config()
    168    config = hassle_config.load_config()
    169    pyproject["project"]["name"] = args.name
    170    pyproject["project"]["authors"] = config["authors"]
    171    pyproject["project"]["description"] = args.description
    172    pyproject["project"]["dependencies"] = args.dependencies
    173    pyproject["project"]["keywords"] = args.keywords
    174    if args.operating_system:
    175        pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join(
    176            args.operating_system
    177        )
    178    if args.no_license:
    179        pyproject["project"]["classifiers"].pop(1)
    180    for field in config["project_urls"]:
    181        pyproject["project"]["urls"][field] = config["project_urls"][field].replace(
    182            "$name", args.name
    183        )
    184    if args.add_script:
    185        pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main"
    186    (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

    189def create_source_files(srcdir: Pathier, filelist: list[str]):
    190    """Generate empty source files in ./{package_name}/src/{package_name}/"""
    191    srcdir.mkdir(parents=True, exist_ok=True)
    192    for file in filelist:
    193        (srcdir / file).touch()

Generate empty source files in ./{package_name}/src/{package_name}/

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

    196def create_readme(targetdir: Pathier, args: argparse.Namespace):
    197    """Create README.md in ./{package_name}
    198    from readme_template and args."""
    199    readme = (root / "README_template.md").read_text()
    200    readme = readme.replace("$name", args.name).replace(
    201        "$description", args.description
    202    )
    203    (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

    206def create_license(targetdir: Pathier):
    207    """Add MIT license file to ./{package_name} ."""
    208    license_template = (root / "license_template.txt").read_text()
    209    license_template = license_template.replace("$year", str(datetime.now().year))
    210    (targetdir / "LICENSE.txt").write_text(license_template)

Add MIT license file to ./{package_name} .

def create_gitignore(targetdir: pathier.pathier.Pathier): View Source

    213def create_gitignore(targetdir: Pathier):
    214    """Add .gitignore to ./{package_name}"""
    215    (root / ".gitignore_template").copy(targetdir / ".gitignore", True)

Add .gitignore to ./{package_name}

def create_vscode_settings(targetdir: pathier.pathier.Pathier): View Source

    218def create_vscode_settings(targetdir: Pathier):
    219    """Add settings.json to ./.vscode"""
    220    vsdir = targetdir / ".vscode"
    221    vsdir.mkdir(parents=True, exist_ok=True)
    222    (root / ".vscode_template").copy(vsdir / "settings.json", True)

Add settings.json to ./.vscode

def main(args: argparse.Namespace = None): View Source

    225def main(args: argparse.Namespace = None):
    226    if not args:
    227        args = get_args()
    228    if not args.not_package:
    229        try:
    230            if check_pypi_for_name(args.name):
    231                print(f"{args.name} already exists on pypi.org")
    232                if not get_answer("Continue anyway?"):
    233                    sys.exit(0)
    234        except Exception as e:
    235            print(e)
    236            print(
    237                f"Couldn't verify that {args.name} doesn't already exist on pypi.org ."
    238            )
    239            if not get_answer("Continue anyway?"):
    240                sys.exit(0)
    241    try:
    242        targetdir: Pathier = Pathier.cwd() / args.name
    243        try:
    244            targetdir.mkdir(parents=True, exist_ok=False)
    245        except:
    246            print(f"{targetdir} already exists.")
    247            if not get_answer("Overwrite?"):
    248                sys.exit(0)
    249        if not args.not_package:
    250            create_pyproject_file(targetdir, args)
    251        create_source_files(
    252            targetdir if args.not_package else (targetdir / "src" / args.name),
    253            args.source_files[1:] if args.not_package else args.source_files,
    254        )
    255        create_readme(targetdir, args)
    256        if not args.not_package:
    257            generate_test_files(targetdir)
    258            create_vscode_settings(targetdir)
    259        create_gitignore(targetdir)
    260        if not args.no_license:
    261            create_license(targetdir)
    262        os.chdir(targetdir)
    263        os.system("git init -b main")
    264
    265    except Exception as e:
    266        if not "Aborting new package creation" in str(e):
    267            print(e)
    268        if get_answer("Delete created files?"):
    269            targetdir.delete()