Test Failed
Push — main ( c4d3e0...64a665 )
by Douglas
06:02
created

tyrannosaurus.cli.CliCommands.commands()   A

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
"""
2
Command-line interface.
3
4
Original source: https://github.com/dmyersturnbull/tyrannosaurus
5
Copyright 2020–2021 Douglas Myers-Turnbull
6
Licensed under the Apache License, Version 2.0 (the "License");
7
you may not use this file except in compliance with the License.
8
You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
9
"""
10
from __future__ import annotations
11
12
import inspect
13
import logging
14
import os
15
import re
16
from pathlib import Path
17
from dataclasses import dataclass
18
from subprocess import check_call  # nosec
19
from typing import Optional, Sequence
20
21
import typer
22
from typer.models import ArgumentInfo, OptionInfo
23
24
from tyrannosaurus.clean import Clean
25
from tyrannosaurus.recipes import Recipe
26
from tyrannosaurus.envs import CondaEnv
27
from tyrannosaurus.context import Context
28
from tyrannosaurus.enums import DevStatus, License
29
from tyrannosaurus.helpers import _Env
30
from tyrannosaurus.new import New
31
from tyrannosaurus.sync import Sync
32
from tyrannosaurus.update import Update
33
34
logger = logging.getLogger(__package__)
35
36
37
def flag(desc: str, hidden: bool = False) -> typer.Option:
38
    return typer.Option(False, help=desc, hidden=hidden)
39
40
41
class _DevNull:  # pragma: no cover
42
    """Pretends to write but doesn't."""
43
44
    def write(self, msg):
45
        pass
46
47
    def flush(self):
48
        pass
49
50
    def close(self):
51
        pass
52
53
    def __enter__(self):
54
        return self
55
56
    def __exit__(self, exc_type, exc_value, traceback):
57
        self.close()
58
59
60
class Msg:
61
    @classmethod
62
    def success(cls, msg: str) -> None:
63
        msg = typer.style(msg, fg=typer.colors.BLUE, bold=True)
64
        typer.echo(msg)
65
66
    @classmethod
67
    def info(cls, msg: str) -> None:
68
        typer.echo(msg)
69
70
    @classmethod
71
    def failure(cls, msg: str) -> None:
72
        msg = typer.style(msg, fg=typer.colors.RED, bold=True)
73
        typer.echo(msg)
74
75
    @classmethod
76
    def write_info(cls):
77
        # avoid importing above, just in case a user runs --version, --info, or info on an improperly installed version
78
        from tyrannosaurus import __date__, __version__
79
80
        Msg.info(f"Tyrannosaurus v{__version__} ({__date__})")
81
82
83
@dataclass(frozen=True, repr=True)
84
class CliState:
85
    dry_run: bool = False
86
    verbose: bool = False
87
88
    def __post_init__(self):
89
        if self.verbose:
90
            logger.setLevel(logging.DEBUG)
91
92
93
def tyranno_main(
94
    version: bool = flag("Write version and exit"),
95
    info: bool = flag("Write info and exit (same as 'tyrannosaurus info')"),
96
):
97
    """
98
    Tyrannosaurus.
99
    Tyrannosaurus can create new modern Python projects from a template
100
    and synchronize metadata across the project.
101
    """
102
    if version or info:
103
        Msg.write_info()
104
        raise typer.Exit()
105
106
107
cli = typer.Typer(callback=tyranno_main, add_completion=True)
108
109
110
class CliCommands:
111
    """
112
    Commands for Tyrannosaurus.
113
    """
114
115
    @classmethod
116
    def commands(cls):
117
        return [cls.new, cls.sync, cls.env, cls.recipe, cls.update, cls.clean, cls.info, cls.build]
118
119
    _APACHE2 = typer.Option(License.apache2)
120
    _ENV_YAML = Path("environment.yml")
121
122
    @staticmethod
123
    @cli.command()
124
    def new(
125
        name: str = typer.Argument(
126
            "project", help="The name of the project, including any dashes or capital letters"
127
        ),
128
        license: str = typer.Option(
129
            _APACHE2, help=f"Name of the license. One of {', '.join(str(s) for s in License)}"
130
        ),
131
        user: Optional[str] = typer.Option(None, help="Name of GitHub user or org"),
132
        authors: Optional[str] = typer.Option(None, help="List of author names, comma-separated"),
133
        description: str = typer.Option(
134
            "A Python project", help="A <100 char description for the project"
135
        ),
136
        keywords: str = typer.Option("", help="A list of <= 5 keywords, comma-separated"),
137
        version: str = typer.Option("0.1.0", help="A semantic version (for your project)"),
138
        status: Optional[DevStatus] = typer.Option(
139
            None,
140
            help="""
141
        A PyPi classifier for development status; if None, defaults to 'alpha' if version<1.0 else 'production'
142
        """,
143
        ),
144
        track: bool = flag(
145
            "Track a remote repo (should be an empty repo; otherwise there will be a merge conflict)"
146
        ),
147
        extras: bool = flag("Include uncommon files like codemeta.json"),
148
        tyranno: str = typer.Option(
149
            "current",
150
            help="""
151
        Version of tyrannosaurus to use as the template; can be:
152
                     an exact version number,
153
                     'current' for the currently installed version,
154
                     'stable' for the latest stable version,
155
                     or 'latest' for the bleeding-edge version
156
        """,
157
        ),
158
        prompt: bool = flag("Prompt for (this) info"),
159
        verbose: bool = flag("Output more information"),
160
    ) -> None:  # pragma: no cover
161
        """
162
        Create a new project.
163
        """
164
        state = CliState(verbose=verbose)
165
        if version.startswith("v"):
166
            version = version[1:]
167
        if status is None:
168
            status = DevStatus.guess_from_version(version)
169
        if prompt:
170
            name = typer.prompt("name", type=str, default=name)
171
            description = typer.prompt("description", type=str, default="A new project")
172
            version = typer.prompt("version", type=str, default="0.1.0")
173
            if version.startswith("v"):
174
                version = version[1:]
175
            if status is None:
176
                status = DevStatus.guess_from_version(version)
177
            status = typer.prompt("status", type=DevStatus, default=status)
178
            license = typer.prompt("license", type=License, default="apache2").lower()
179
            user = typer.prompt(
180
                "user", type=str, prompt_suffix=" [default: from 'git config']", default=user
181
            )
182
            authors = typer.prompt(
183
                "authors",
184
                type=str,
185
                prompt_suffix=" [comma-separated; default: from 'git config']",
186
                default=authors,
187
            )
188
            description = typer.prompt("description", type=str, default=description)
189
            keywords = typer.prompt(
190
                "keywords", type=str, prompt_suffix=" [comma-separated]", default=keywords
191
            )
192
            track = typer.prompt("track", type=bool, default=track)
193
            tyranno = typer.prompt(
194
                "tyranno",
195
                type=str,
196
                prompt_suffix=" ['current', 'stable', 'latest', or a version]",
197
                default=tyranno,
198
            )
199
        e = _Env(user=user, authors=authors)
200
        keywords = keywords.split(",")
201
        path = Path(name)
202
        New(
203
            name,
204
            license_name=license,
205
            username=e.user,
206
            authors=e.authors,
207
            description=description,
208
            keywords=keywords,
209
            version=version,
210
            status=status,
211
            should_track=track,
212
            tyranno_vr=tyranno.strip(" \r\n\t"),
213
            extras=extras,
214
            debug=state.verbose,
215
        ).create(path)
216
        Msg.success(f"Done! Created a new repository under {name}")
217
        Msg.success(
218
            "See https://tyrannosaurus.readthedocs.io/en/latest/guide.html#to-do-list-for-new-projects"
219
        )
220
        if track:
221
            repo_to_track = f"https://github.com/{e.user}/{name.lower()}.git"
222
            Msg.info(f"Tracking {repo_to_track}")
223
            Msg.info("Checked out branch main tracking origin/main")
224
225
    @staticmethod
226
    @cli.command()
227
    def sync(
228
        dry_run: bool = flag("Don't write; just output what it would do"),
229
        verbose: bool = flag("Output more information"),
230
    ) -> None:  # pragma: no cover
231
        """
232
        Sync project metadata between configured files.
233
        """
234
        state = CliState(dry_run=dry_run, verbose=verbose)
235
        context = Context(Path(os.getcwd()), dry_run=state.dry_run)
236
        Msg.info("Syncing metadata...")
237
        Msg.info("Currently, only targets 'init' and 'recipe' are implemented.")
238
        targets = Sync(context).sync()
239
        Msg.success(f"Done. Synced to {len(targets)} targets: {targets}")
240
241
    @staticmethod
242
    @cli.command()
243
    def env(
244
        path: Path = typer.Option(_ENV_YAML, help="Write to this path"),
245
        name: Optional[str] = typer.Option(
246
            None, help="The name of the environment; defaults to the project name"
247
        ),
248
        dev: bool = flag("Include development/build dependencies"),
249
        extras: bool = flag("Include optional dependencies"),
250
        dry_run: bool = flag("Don't write; just output what it would do"),
251
        verbose: bool = flag("Output more information"),
252
    ) -> None:  # pragma: no cover
253
        """
254
        Generate an Anaconda environment file.
255
        """
256
        state = CliState(dry_run=dry_run, verbose=verbose)
257
        typer.echo("Writing environment file...")
258
        context = Context(Path(os.getcwd()), dry_run=state.dry_run)
259
        if name is None:
260
            name = context.project
261
        CondaEnv(name, dev=dev, extras=extras).create(context, path)
262
        Msg.success(f"Wrote environment file {path}")
263
264
    @staticmethod
265
    @cli.command()
266
    def recipe(
267
        dry_run: bool = flag("Don't write; just output what it would do"),
268
        verbose: bool = flag("Output more information"),
269
    ) -> None:  # pragma: no cover
270
        """
271
        Generate a Conda recipe using grayskull.
272
        """
273
        state = CliState(dry_run=dry_run, verbose=verbose)
274
        dry_run = state.dry_run
275
        context = Context(Path(os.getcwd()), dry_run=dry_run)
276
        output_path = context.path / "recipes"
277
        Recipe(context).create(output_path)
278
        Msg.success(f"Generated a new recipe under {output_path}")
279
280
    @staticmethod
281
    @cli.command()
282
    def update(
283
        auto_fix=flag("Update dependencies in place (not supported yet)", hidden=True),
284
        verbose: bool = flag("Output more information"),
285
    ) -> None:  # pragma: no cover
286
        """
287
        Find and list dependencies that could be updated.
288
289
        Args:
290
            auto_fix: Update dependencies in place (not supported yet)
291
            verbose: Output more information
292
        """
293
        state = CliState(verbose=verbose)
294
        context = Context(Path(os.getcwd()), dry_run=not auto_fix)
295
        updates, dev_updates = Update(context).update()
296
        Msg.info("Main updates:")
297
        for pkg, (old, up) in updates.items():
298
            Msg.info(f"    {pkg}:  {old} --> {up}")
299
        Msg.info("Dev updates:")
300
        for pkg, (old, up) in dev_updates.items():
301
            Msg.info(f"    {pkg}:  {old} --> {up}")
302
        if not state.dry_run:
303
            Msg.failure("Auto-fixing is not supported yet!")
304
305
    @staticmethod
306
    @cli.command()
307
    def clean(
308
        dists: bool = flag("Remove dists"),
309
        aggressive: bool = flag("Delete additional files, including .swp and .ipython_checkpoints"),
310
        hard_delete: bool = flag("All shutil.rmtree instead of moving to .tyrannosaurus"),
311
        dry_run: bool = flag("Don't write; just output what it would do"),
312
        verbose: bool = flag("Output more information"),
313
    ) -> None:  # pragma: no cover
314
        """
315
        Remove unwanted files.
316
        Deletes the contents of ``.tyrannosaurus``.
317
        Then trashes temporary and unwanted files and directories to a tree under ``.tyrannosaurus``.
318
        """
319
        state = CliState(verbose=verbose, dry_run=dry_run)
320
        dry_run = state.dry_run
321
        trashed = Clean(dists, aggressive, hard_delete, dry_run).clean(Path(os.getcwd()))
322
        Msg.info(f"Trashed {len(trashed)} paths.")
323
324
    @staticmethod
325
    @cli.command()
326
    def info() -> None:  # pragma: no cover
327
        """
328
        Print Tyrannosaurus info.
329
        """
330
        Msg.write_info()
331
332
    @staticmethod
333
    @cli.command()
334
    def build(
335
        bare: bool = flag("Do not use tox or virtualenv."),
336
        dry_run: bool = flag(
337
            "Just output the commands to stdout (don't run them). Useful for making a script template."
338
        ),
339
        verbose: bool = flag("Output more information"),
340
    ) -> None:  # pragma: no cover
341
        """
342
        Syncs, builds, and tests your project.
343
344
        If ``bare`` is NOT set, runs:
345
            - tyrannosaurus sync
346
            - poetry lock
347
            - tox
348
            - tyrannosaurus clean
349
350
        ---------------------------------------------------------------
351
352
        If the ``bare`` IS set:
353
        Runs the commands without tox and without creating a new virtualenv.
354
        This can be useful if you're using Conda and have a dependency only available through Anaconda.
355
        It's also often faster.
356
        This command is for convenience and isn't very customizable.
357
        In this case, runs:
358
            - tyrannosaurus sync
359
            - poetry lock
360
            - pre-commit run check-toml
361
            - pre-commit run check-yaml
362
            - pre-commit run check-json
363
            - poetry check
364
            - poetry build
365
            - poetry install -v
366
            - poetry run pytest --cov
367
            - poetry run flake8 tyrannosaurus
368
            - poetry run flake8 docs
369
            - poetry run flake8 --ignore=D100,D101,D102,D103,D104,S101 tests
370
            - sphinx-build -b html docs docs/html
371
            - tyrannosaurus clean
372
            - pip install .
373
374
        ---------------------------------------------------------------
375
        """
376
        state = CliState(dry_run=dry_run, verbose=verbose)
377
        CliCommands.build_internal(bare=bare, dry=state.dry_run)
378
379
    @staticmethod
380
    def build_internal(bare: bool = False, dry: bool = False) -> Sequence[str]:
381
        split = CliCommands.build.__doc__.split("-" * 40)
382
        cmds = [
383
            line.strip(" -")
384
            for line in split[1 if bare else 0].splitlines()
385
            if line.strip().startswith("- ")
386
        ]
387
        if not dry:
388
            for cmd in cmds:
389
                Msg.info("Running: " + cmd)
390
                check_call(cmd.split(" "))  # nosec
391
        return cmds
392
393
394
def _fix_docstrings(commands):
395
    for f in commands:
396
        if "Args:" in [q.strip() for q in f.__doc__.splitlines()]:
397
            continue
398
        f.__doc__ += "\n" + " " * 8 + "Args:\n"
399
        for p in inspect.signature(f).parameters.values():
400
            arg = p.default
401
            if arg is not None:
402
                d = arg.default
403
                if isinstance(d, (OptionInfo, ArgumentInfo)) and hasattr(d, "default"):
404
                    d = d.default
405
                try:
406
                    h = re.compile(" +").sub(arg.help.replace("\n", "").strip(), " ")
407
                    f.__doc__ += " " * 12 + p.name + ": " + h + "\n"
408
                    if d is not False:
409
                        f.__doc__ += " " * (12 + 1 + len(p.name)) + f" [default: {str(d)}]\n"
410
                except (AttributeError, TypeError):
411
                    f.__doc__ += " " * 12 + p.name + " "
412
413
414
if __name__ == "__main__":
415
    cli()
416
417
# Do this after calling cli()!
418
_fix_docstrings(CliCommands.commands())
419
_fix_docstrings([tyranno_main])
420