hassle.new_project

  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 until a 'y' or a 'n' is received."""
111    ans = ""
112    question = question.strip()
113    if "?" not in question:
114        question += "?"
115    question += " (y/n): "
116    while ans not in ["y", "yes", "no", "n"]:
117        ans = input(question).strip().lower()
118        if ans in ["y", "yes"]:
119            return True
120        elif ans in ["n", "no"]:
121            return False
122        else:
123            print("Invalid answer.")
124
125
126def check_pypi_for_name(package_name: str) -> bool:
127    """Check if a package with package_name already exists on `pypi.org`.
128    Returns `True` if package name exists.
129    Only checks the first page of results."""
130    url = f"https://pypi.org/search/?q={package_name.lower()}"
131    response = requests.get(url)
132    if response.status_code != 200:
133        raise RuntimeError(
134            f"Error: pypi.org returned status code: {response.status_code}"
135        )
136    soup = BeautifulSoup(response.text, "html.parser")
137    pypi_packages = [
138        span.text.lower()
139        for span in soup.find_all("span", class_="package-snippet__name")
140    ]
141    return package_name in pypi_packages
142
143
144def check_pypi_for_name_cli():
145    parser = argparse.ArgumentParser()
146    parser.add_argument("name", type=str)
147    args = parser.parse_args()
148    if check_pypi_for_name(args.name):
149        print(f"{args.name} is already taken.")
150    else:
151        print(f"{args.name} is available.")
152
153
154def create_pyproject_file(targetdir: Pathier, args: argparse.Namespace):
155    """Create `pyproject.toml` in `./{project_name}` from args, pyproject_template, and hassle_config."""
156    pyproject = (root / "pyproject_template.toml").loads()
157    if not hassle_config.config_exists():
158        hassle_config.warn()
159        if not get_answer("Continue creating new package with blank config?"):
160            raise Exception("Aborting new package creation")
161        else:
162            print("Creating blank hassle_config.toml...")
163            hassle_config.create_config()
164    config = hassle_config.load_config()
165    pyproject["project"]["name"] = args.name
166    pyproject["project"]["authors"] = config["authors"]
167    pyproject["project"]["description"] = args.description
168    pyproject["project"]["dependencies"] = args.dependencies
169    pyproject["project"]["keywords"] = args.keywords
170    if args.operating_system:
171        pyproject["project"]["classifiers"][2] = "Operating System :: " + " ".join(
172            args.operating_system
173        )
174    if args.no_license:
175        pyproject["project"]["classifiers"].pop(1)
176    for field in config["project_urls"]:
177        pyproject["project"]["urls"][field] = config["project_urls"][field].replace(
178            "$name", args.name
179        )
180    if args.add_script:
181        pyproject["project"]["scripts"][args.name] = f"{args.name}.{args.name}:main"
182    (targetdir / "pyproject.toml").dumps(pyproject)
183
184
185def create_source_files(srcdir: Pathier, filelist: list[str]):
186    """Generate empty source files in `./{package_name}/src/{package_name}/`"""
187    srcdir.mkdir(parents=True, exist_ok=True)
188    for file in filelist:
189        (srcdir / file).touch()
190
191
192def create_readme(targetdir: Pathier, args: argparse.Namespace):
193    """Create `README.md` in `./{package_name}` 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)
199
200
201def create_license(targetdir: Pathier):
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)
206
207
208def create_gitignore(targetdir: Pathier):
209    """Add `.gitignore` to `./{package_name}`"""
210    (root / ".gitignore_template").copy(targetdir / ".gitignore", True)
211
212
213def create_vscode_settings(targetdir: Pathier):
214    """Add `settings.json` to `./.vscode`"""
215    vsdir = targetdir / ".vscode"
216    vsdir.mkdir(parents=True, exist_ok=True)
217    (root / ".vscode_template").copy(vsdir / "settings.json", True)
218
219
220def main(args: argparse.Namespace = None):
221    if not args:
222        args = get_args()
223    if not args.not_package:
224        try:
225            if check_pypi_for_name(args.name):
226                print(f"{args.name} already exists on pypi.org")
227                if not get_answer("Continue anyway?"):
228                    sys.exit(0)
229        except Exception as e:
230            print(e)
231            print(
232                f"Couldn't verify that {args.name} doesn't already exist on pypi.org ."
233            )
234            if not get_answer("Continue anyway?"):
235                sys.exit(0)
236    try:
237        targetdir: Pathier = Pathier.cwd() / args.name
238        try:
239            targetdir.mkdir(parents=True, exist_ok=False)
240        except:
241            print(f"{targetdir} already exists.")
242            if not get_answer("Overwrite?"):
243                sys.exit(0)
244        if not args.not_package:
245            create_pyproject_file(targetdir, args)
246        create_source_files(
247            targetdir if args.not_package else (targetdir / "src" / args.name),
248            args.source_files[1:] if args.not_package else args.source_files,
249        )
250        create_readme(targetdir, args)
251        if not args.not_package:
252            generate_test_files(targetdir)
253            create_vscode_settings(targetdir)
254        create_gitignore(targetdir)
255        if not args.no_license:
256            create_license(targetdir)
257        os.chdir(targetdir)
258        os.system("git init -b main")
259
260    except Exception as e:
261        if not "Aborting new package creation" in str(e):
262            print(e)
263        if get_answer("Delete created files?"):
264            targetdir.delete()
265
266
267if __name__ == "__main__":
268    main(get_args())
def get_args() -> argparse.Namespace:
 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:
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.")

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

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():
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.")
def create_pyproject_file(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
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    (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]):
186def create_source_files(srcdir: Pathier, filelist: list[str]):
187    """Generate empty source files in `./{package_name}/src/{package_name}/`"""
188    srcdir.mkdir(parents=True, exist_ok=True)
189    for file in filelist:
190        (srcdir / file).touch()

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

def create_readme(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
193def create_readme(targetdir: Pathier, args: argparse.Namespace):
194    """Create `README.md` in `./{package_name}` from readme_template and args."""
195    readme = (root / "README_template.md").read_text()
196    readme = readme.replace("$name", args.name).replace(
197        "$description", args.description
198    )
199    (targetdir / "README.md").write_text(readme)

Create README.md in ./{package_name} from readme_template and args.

def create_license(targetdir: pathier.pathier.Pathier):
202def create_license(targetdir: Pathier):
203    """Add MIT license file to `./{package_name}`."""
204    license_template = (root / "license_template.txt").read_text()
205    license_template = license_template.replace("$year", str(datetime.now().year))
206    (targetdir / "LICENSE.txt").write_text(license_template)

Add MIT license file to ./{package_name}.

def create_gitignore(targetdir: pathier.pathier.Pathier):
209def create_gitignore(targetdir: Pathier):
210    """Add `.gitignore` to `./{package_name}`"""
211    (root / ".gitignore_template").copy(targetdir / ".gitignore", True)

Add .gitignore to ./{package_name}

def create_vscode_settings(targetdir: pathier.pathier.Pathier):
214def create_vscode_settings(targetdir: Pathier):
215    """Add `settings.json` to `./.vscode`"""
216    vsdir = targetdir / ".vscode"
217    vsdir.mkdir(parents=True, exist_ok=True)
218    (root / ".vscode_template").copy(vsdir / "settings.json", True)

Add settings.json to ./.vscode

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