tyrannosaurus.cli   B
last analyzed

Complexity

Total Complexity 47

Size/Duplication

Total Lines 425
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 268
dl 0
loc 425
rs 8.64
c 0
b 0
f 0
wmc 47

20 Methods

Rating   Name   Duplication   Size   Complexity  
A CliCommands.info() 0 7 1
A CliCommands.build_internal() 0 13 4
A _DevNull.close() 0 2 1
A _DevNull.flush() 0 2 1
A CliCommands.env() 0 22 2
A _DevNull.write() 0 2 1
A CliCommands.clean() 0 22 1
A CliCommands.update() 0 24 4
A Msg.write_info() 0 6 1
A _DevNull.__enter__() 0 2 1
A _DevNull.__exit__() 0 2 1
A CliCommands.recipe() 0 15 1
A CliCommands.sync() 0 15 1
A CliCommands.commands() 0 3 1
A Msg.failure() 0 4 1
A Msg.success() 0 4 1
A Msg.info() 0 3 1
C CliCommands.new() 0 106 7
A CliState.__post_init__() 0 3 2
A CliCommands.build() 0 46 1

3 Functions

Rating   Name   Duplication   Size   Complexity  
A tyranno_main() 0 12 3
A flag() 0 3 1
C _fix_docstrings() 0 18 9

How to fix   Complexity   

Complexity

Complex classes like tyrannosaurus.cli often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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