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