hassle.new_project

  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
196
197def create_readme(targetdir: Pathier, args: argparse.Namespace):
198    """Create `README.md` in `./{package_name}` 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)
204
205
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)
211
212
213def create_gitignore(targetdir: Pathier):
214    """Add `.gitignore` to `./{package_name}`"""
215    (root / ".gitignore_template").copy(targetdir / ".gitignore", True)
216
217
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)
223
224
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        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        git = Git()
263        git.new_repo()
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()
270
271
272if __name__ == "__main__":
273    main(get_args())
def get_args() -> argparse.Namespace:
 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:
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:
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():
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):
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]):
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()

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

def create_readme(targetdir: pathier.pathier.Pathier, args: argparse.Namespace):
198def create_readme(targetdir: Pathier, args: argparse.Namespace):
199    """Create `README.md` in `./{package_name}` 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)

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

def create_license(targetdir: pathier.pathier.Pathier):
207def create_license(targetdir: Pathier):
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)

Add MIT license file to ./{package_name}.

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

Add .gitignore to ./{package_name}

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

Add settings.json to ./.vscode

def main(args: argparse.Namespace = None):
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: Pathier = Pathier.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        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        git = Git()
264        git.new_repo()
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            targetdir.delete()