• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

waketzheng / fast-dev-cli / 19064148667

04 Nov 2025 09:33AM UTC coverage: 87.394% (-0.3%) from 87.713%
19064148667

push

github

waketzheng
Bump version: 0.19.1 → 0.19.2

1 of 1 new or added line in 1 file covered. (100.0%)

81 existing lines in 1 file now uncovered.

929 of 1063 relevant lines covered (87.39%)

4.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.38
/fast_dev_cli/cli.py
1
from __future__ import annotations
5✔
2

3
import contextlib
5✔
4
import functools
5✔
5
import importlib.metadata as importlib_metadata
5✔
6
import os
5✔
7
import platform
5✔
8
import re
5✔
9
import shlex
5✔
10
import shutil
5✔
11
import subprocess  # nosec:B404
5✔
12
import sys
5✔
13
from functools import cached_property
5✔
14
from pathlib import Path
5✔
15
from typing import TYPE_CHECKING, Any, Literal, cast, get_args, overload
5✔
16

17
import typer
5✔
18
from click import UsageError
5✔
19
from typer import Exit, Option, echo, secho
5✔
20
from typer.models import ArgumentInfo, OptionInfo
5✔
21

22
try:
5✔
23
    from . import __version__
5✔
24
except ImportError:  # pragma: no cover
25
    from importlib import import_module as _import  # For local unittest
26

27
    __version__ = _import(Path(__file__).parent.name).__version__
28

29
if sys.version_info >= (3, 11):  # pragma: no cover
30
    from enum import StrEnum
31

32
    import tomllib
33
else:  # pragma: no cover
34
    from enum import Enum
35

36
    import tomli as tomllib
37

38
    class StrEnum(str, Enum):
39
        __str__ = str.__str__
40

41

42
if TYPE_CHECKING:
43
    if sys.version_info >= (3, 11):
44
        from typing import Self
45
    else:
46
        from typing_extensions import Self
47

48
cli = typer.Typer(no_args_is_help=True)
5✔
49
DryOption = Option(False, "--dry", help="Only print, not really run shell command")
5✔
50
TOML_FILE = "pyproject.toml"
5✔
51
ToolName = Literal["uv", "pdm", "poetry"]
5✔
52
ToolOption = Option(
5✔
53
    "auto", "--tool", help="Explicit declare manage tool (default to auto detect)"
54
)
55

56

57
class FastDevCliError(Exception):
5✔
58
    """Basic exception of this library, all custom exceptions inherit from it"""
59

60

61
class ShellCommandError(FastDevCliError):
5✔
62
    """Raise if cmd command returncode is not zero"""
63

64

65
class ParseError(FastDevCliError):
5✔
66
    """Raise this if parse dependence line error"""
67

68

69
class EnvError(FastDevCliError):
5✔
70
    """Raise when expected to be managed by poetry, but toml file not found."""
71

72

73
def poetry_module_name(name: str) -> str:
5✔
74
    """Get module name that generated by `poetry new`"""
75
    try:
5✔
76
        from packaging.utils import canonicalize_name
5✔
77
    except ImportError:
×
78

79
        def canonicalize_name(s: str) -> str:  # type:ignore[misc]
×
80
            return re.sub(r"[-_.]+", "-", s)
×
81

82
    return canonicalize_name(name).replace("-", "_").replace(" ", "_")
5✔
83

84

85
def is_emoji(char: str) -> bool:
5✔
86
    try:
5✔
87
        import emoji
5✔
88
    except ImportError:
×
89
        pass
×
90
    else:
91
        return emoji.is_emoji(char)
5✔
92
    if re.match(r"[\w\d\s]", char):
×
93
        return False
×
94
    return not "\u4e00" <= char <= "\u9fff"  # Chinese character
×
95

96

97
@functools.cache
5✔
98
def is_windows() -> bool:
5✔
99
    return platform.system() == "Windows"
5✔
100

101

102
def yellow_warn(msg: str) -> None:
5✔
103
    if is_windows() and (encoding := sys.stdout.encoding) != "utf-8":
5✔
104
        msg = msg.encode(encoding, errors="ignore").decode(encoding)
×
105
    secho(msg, fg="yellow")
5✔
106

107

108
def load_bool(name: str, default: bool = False) -> bool:
5✔
109
    if not (v := os.getenv(name)):
5✔
110
        return default
5✔
111
    if (lower := v.lower()) in ("0", "false", "f", "off", "no", "n"):
5✔
112
        return False
5✔
113
    elif lower in ("1", "true", "t", "on", "yes", "y"):
5✔
114
        return True
5✔
115
    secho(f"WARNING: can not convert value({v!r}) of {name} to bool!")
5✔
116
    return default
5✔
117

118

119
def is_venv() -> bool:
5✔
120
    """Whether in a virtual environment(also work for poetry)"""
121
    return hasattr(sys, "real_prefix") or (
5✔
122
        hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
123
    )
124

125

126
class Shell:
5✔
127
    def __init__(self, cmd: list[str] | str, **kw: Any) -> None:
5✔
128
        self._cmd = cmd
5✔
129
        self._kw = kw
5✔
130

131
    @staticmethod
5✔
132
    def run_by_subprocess(
5✔
133
        cmd: list[str] | str, **kw: Any
134
    ) -> subprocess.CompletedProcess[str]:
135
        if isinstance(cmd, str):
5✔
136
            kw.setdefault("shell", True)
5✔
137
        return subprocess.run(cmd, **kw)  # nosec:B603
5✔
138

139
    @property
5✔
140
    def command(self) -> list[str] | str:
5✔
141
        command: list[str] | str = self._cmd
5✔
142
        if (
5✔
143
            isinstance(command, str)
144
            and "shell" not in self._kw
145
            and not (set(self._cmd) & {"|", ">", "&"})
146
        ):
147
            command = shlex.split(command)
5✔
148
        return command
5✔
149

150
    def _run(self) -> subprocess.CompletedProcess[str]:
5✔
151
        return self.run_by_subprocess(self.command, **self._kw)
5✔
152

153
    def run(self, verbose: bool = False, dry: bool = False) -> int:
5✔
154
        if verbose:
5✔
155
            echo(f"--> {self._cmd}")
5✔
156
        if dry:
5✔
157
            return 0
5✔
158
        return self._run().returncode
5✔
159

160
    def check_call(self) -> bool:
5✔
161
        self._kw.update(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
5✔
162
        return self.run() == 0
5✔
163

164
    def capture_output(self, raises: bool = False) -> str:
5✔
165
        self._kw.update(capture_output=True, encoding="utf-8")
5✔
166
        r = self._run()
5✔
167
        if raises and r.returncode != 0:
5✔
168
            raise ShellCommandError(r.stderr)
5✔
169
        return (r.stdout or r.stderr or "").strip()
5✔
170

171
    def finish(
5✔
172
        self, env: dict[str, str] | None = None, _exit: bool = False, dry=False
173
    ) -> subprocess.CompletedProcess[str]:
174
        self.run(verbose=True, dry=True)
5✔
175
        if _ensure_bool(dry):
5✔
176
            return subprocess.CompletedProcess("", 0)
5✔
177
        if env is not None:
5✔
178
            self._kw["env"] = {**os.environ, **env}
5✔
179
        r = self._run()
5✔
180
        if rc := r.returncode:
5✔
181
            if _exit:
5✔
182
                sys.exit(rc)
5✔
183
            raise Exit(rc)
5✔
184
        return r
5✔
185

186

187
def run_and_echo(
5✔
188
    cmd: str, *, dry: bool = False, verbose: bool = True, **kw: Any
189
) -> int:
190
    """Run shell command with subprocess and print it"""
191
    return Shell(cmd, **kw).run(verbose=verbose, dry=dry)
5✔
192

193

194
def check_call(cmd: str) -> bool:
5✔
195
    return Shell(cmd).check_call()
5✔
196

197

198
def capture_cmd_output(
5✔
199
    command: list[str] | str, *, raises: bool = False, **kw: Any
200
) -> str:
201
    return Shell(command, **kw).capture_output(raises=raises)
5✔
202

203

204
def exit_if_run_failed(
5✔
205
    cmd: str,
206
    env: dict[str, str] | None = None,
207
    _exit: bool = False,
208
    dry: bool = False,
209
    **kw: Any,
210
) -> subprocess.CompletedProcess[str]:
211
    return Shell(cmd, **kw).finish(env=env, _exit=_exit, dry=dry)
5✔
212

213

214
def _parse_version(line: str, pattern: re.Pattern[str]) -> str:
5✔
215
    return pattern.sub("", line).split("#")[0].strip().strip(" '\"")
5✔
216

217

218
def read_version_from_file(
5✔
219
    package_name: str, work_dir: Path | None = None, toml_text: str | None = None
220
) -> str:
221
    if not package_name and toml_text:
5✔
222
        pattern = re.compile(r"version\s*=")
5✔
223
        for line in toml_text.splitlines():
5✔
224
            if pattern.match(line):
5✔
225
                return _parse_version(line, pattern)
5✔
226
    version_file = BumpUp.parse_filename(toml_text, work_dir, package_name)
5✔
227
    if version_file == TOML_FILE:
5✔
228
        if toml_text is None:
5✔
229
            toml_text = Project.load_toml_text()
5✔
230
        context = tomllib.loads(toml_text)
5✔
231
        with contextlib.suppress(KeyError):
5✔
232
            return cast(str, context["project"]["version"])
5✔
233
        with contextlib.suppress(KeyError):  # Poetry V1
5✔
234
            return cast(str, context["tool"]["poetry"]["version"])
5✔
235
        secho(f"WARNING: can not find 'version' item in {version_file}!")
5✔
236
        return "0.0.0"
5✔
237
    pattern = re.compile(r"__version__\s*=")
5✔
238
    for line in Path(version_file).read_text("utf-8").splitlines():
5✔
239
        if pattern.match(line):
5✔
240
            return _parse_version(line, pattern)
5✔
241
    # TODO: remove or refactor the following lines.
242
    if work_dir is None:
×
243
        work_dir = Project.get_work_dir()
×
244
    package_dir = work_dir / package_name
×
245
    if (
×
246
        not (init_file := package_dir / "__init__.py").exists()
247
        and not (init_file := work_dir / "src" / package_name / init_file.name).exists()
248
        and not (init_file := work_dir / "app" / init_file.name).exists()
249
    ):
250
        secho("WARNING: __init__.py file does not exist!")
×
251
        return "0.0.0"
×
252

253
    pattern = re.compile(r"__version__\s*=")
×
254
    for line in init_file.read_text("utf-8").splitlines():
×
255
        if pattern.match(line):
×
256
            return _parse_version(line, pattern)
×
257
    secho(f"WARNING: can not find '__version__' var in {init_file}!")
×
258
    return "0.0.0"
×
259

260

261
@overload
262
def get_current_version(
263
    verbose: bool = False,
264
    is_poetry: bool | None = None,
265
    package_name: str | None = None,
266
    *,
267
    check_version: Literal[False] = False,
268
) -> str: ...
269

270

271
@overload
272
def get_current_version(
273
    verbose: bool = False,
274
    is_poetry: bool | None = None,
275
    package_name: str | None = None,
276
    *,
277
    check_version: Literal[True] = True,
278
) -> tuple[bool, str]: ...
279

280

281
def get_current_version(
5✔
282
    verbose: bool = False,
283
    is_poetry: bool | None = None,
284
    package_name: str | None = None,
285
    *,
286
    check_version: bool = False,
287
) -> str | tuple[bool, str]:
288
    if is_poetry is True or Project.manage_by_poetry():
5✔
289
        cmd = ["poetry", "version", "-s"]
5✔
290
        if verbose:
5✔
291
            echo(f"--> {' '.join(cmd)}")
5✔
292
        if out := capture_cmd_output(cmd, raises=True):
5✔
293
            out = out.splitlines()[-1].strip().split()[-1]
5✔
294
        if check_version:
5✔
295
            return True, out
5✔
296
        return out
5✔
297
    toml_text = work_dir = None
5✔
298
    if package_name is None:
5✔
299
        work_dir = Project.get_work_dir()
5✔
300
        toml_text = Project.load_toml_text()
5✔
301
        doc = tomllib.loads(toml_text)
5✔
302
        project_name = doc.get("project", {}).get("name", work_dir.name)
5✔
303
        package_name = re.sub(r"[- ]", "_", project_name)
5✔
304
    local_version = read_version_from_file(package_name, work_dir, toml_text)
5✔
305
    try:
5✔
306
        installed_version = importlib_metadata.version(package_name)
5✔
307
    except importlib_metadata.PackageNotFoundError:
5✔
308
        installed_version = ""
5✔
309
    current_version = local_version or installed_version
5✔
310
    if not current_version:
5✔
311
        raise FastDevCliError(f"Failed to get current version of {package_name!r}")
×
312
    if check_version:
5✔
313
        is_conflict = bool(local_version) and local_version != installed_version
5✔
314
        return is_conflict, current_version
5✔
315
    return current_version
5✔
316

317

318
def _ensure_bool(value: bool | OptionInfo) -> bool:
5✔
319
    if not isinstance(value, bool):
5✔
320
        value = getattr(value, "default", False)
5✔
321
    return value
5✔
322

323

324
def _ensure_str(value: str | OptionInfo | None) -> str:
5✔
325
    if not isinstance(value, str):
5✔
326
        value = getattr(value, "default", "")
5✔
327
    return value
5✔
328

329

330
class DryRun:
5✔
331
    def __init__(self, _exit: bool = False, dry: bool = False) -> None:
5✔
332
        self.dry = _ensure_bool(dry)
5✔
333
        self._exit = _exit
5✔
334

335
    def gen(self) -> str:
5✔
336
        raise NotImplementedError
5✔
337

338
    def run(self) -> None:
5✔
339
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
5✔
340

341

342
class BumpUp(DryRun):
5✔
343
    class PartChoices(StrEnum):
5✔
344
        patch = "patch"
5✔
345
        minor = "minor"
5✔
346
        major = "major"
5✔
347

348
    def __init__(
5✔
349
        self,
350
        commit: bool,
351
        part: str,
352
        filename: str | None = None,
353
        dry: bool = False,
354
        no_sync: bool = False,
355
        emoji: bool | None = None,
356
    ) -> None:
357
        self.commit = commit
5✔
358
        self.part = part
5✔
359
        if filename is None:
5✔
360
            filename = self.parse_filename()
5✔
361
        self.filename = filename
5✔
362
        self._no_sync = no_sync
5✔
363
        self._emoji = emoji
5✔
364
        super().__init__(dry=dry)
5✔
365

366
    @staticmethod
5✔
367
    def get_last_commit_message(raises: bool = False) -> str:
5✔
368
        cmd = 'git show --pretty=format:"%s" -s HEAD'
5✔
369
        return capture_cmd_output(cmd, raises=raises)
5✔
370

371
    @classmethod
5✔
372
    def should_add_emoji(cls) -> bool:
5✔
373
        """
374
        If last commit message is startswith emoji,
375
        add a ⬆️ flag at the prefix of bump up commit message.
376
        """
377
        try:
5✔
378
            first_char = cls.get_last_commit_message(raises=True)[0]
5✔
379
        except (IndexError, ShellCommandError):
5✔
380
            return False
5✔
381
        else:
382
            return is_emoji(first_char)
5✔
383

384
    @staticmethod
5✔
385
    def parse_dynamic_version(
5✔
386
        toml_text: str,
387
        context: dict,
388
        work_dir: Path | None = None,
389
    ) -> str | None:
390
        if work_dir is None:
5✔
391
            work_dir = Project.get_work_dir()
5✔
392
        for tool in ("pdm", "hatch"):
5✔
393
            with contextlib.suppress(KeyError):
5✔
394
                version_path = cast(str, context["tool"][tool]["version"]["path"])
5✔
395
                if (
5✔
396
                    Path(version_path).exists()
397
                    or work_dir.joinpath(version_path).exists()
398
                ):
399
                    return version_path
5✔
400
        # version = { source = "file", path = "fast_dev_cli/__init__.py" }
401
        v_key = "version = "
5✔
402
        p_key = 'path = "'
5✔
403
        for line in toml_text.splitlines():
5✔
404
            if not line.startswith(v_key):
×
405
                continue
×
406
            if p_key in (value := line.split(v_key, 1)[-1].split("#")[0]):
×
407
                filename = value.split(p_key, 1)[-1].split('"')[0]
×
408
                if work_dir.joinpath(filename).exists():
×
409
                    return filename
×
410
        return None
5✔
411

412
    @classmethod
5✔
413
    def parse_filename(
5✔
414
        cls,
415
        toml_text: str | None = None,
416
        work_dir: Path | None = None,
417
        package_name: str | None = None,
418
    ) -> str:
419
        if toml_text is None:
5✔
420
            toml_text = Project.load_toml_text()
5✔
421
        context = tomllib.loads(toml_text)
5✔
422
        by_version_plugin = False
5✔
423
        try:
5✔
424
            ver = context["project"]["version"]
5✔
425
        except KeyError:
5✔
426
            pass
5✔
427
        else:
428
            if isinstance(ver, str):
5✔
429
                if ver in ("0", "0.0.0"):
5✔
430
                    by_version_plugin = True
5✔
431
                elif re.match(r"\d+\.\d+\.\d+", ver):
5✔
432
                    return TOML_FILE
5✔
433
        if not by_version_plugin:
5✔
434
            try:
5✔
435
                version_value = context["tool"]["poetry"]["version"]
5✔
436
            except KeyError:
5✔
437
                if not Project.manage_by_poetry() and (
5✔
438
                    filename := cls.parse_dynamic_version(toml_text, context, work_dir)
439
                ):
440
                    return filename
5✔
441
            else:
442
                by_version_plugin = version_value in ("0", "0.0.0", "init")
5✔
443
        if by_version_plugin:
5✔
444
            return cls.parse_plugin_version(context, package_name)
5✔
445

446
        return TOML_FILE
5✔
447

448
    @staticmethod
5✔
449
    def parse_plugin_version(context: dict, package_name: str | None) -> str:
5✔
450
        try:
5✔
451
            package_item = context["tool"]["poetry"]["packages"]
5✔
452
        except KeyError:
5✔
453
            try:
5✔
454
                project_name = context["project"]["name"]
5✔
455
            except KeyError:
5✔
456
                packages = []
5✔
457
            else:
458
                packages = [(poetry_module_name(project_name), "")]
×
459
        else:
460
            packages = [
5✔
461
                (j, i.get("from", "")) for i in package_item if (j := i.get("include"))
462
            ]
463
        # In case of managed by `poetry-plugin-version`
464
        cwd = Path.cwd()
5✔
465
        pattern = re.compile(r"__version__\s*=\s*['\"]")
5✔
466
        ds: list[Path] = []
5✔
467
        if package_name is not None:
5✔
468
            packages.insert(0, (package_name, ""))
×
469
        for package_name, source_dir in packages:
5✔
470
            ds.append(cwd / package_name)
5✔
471
            ds.append(cwd / "src" / package_name)
5✔
472
            if source_dir and source_dir != "src":
5✔
473
                ds.append(cwd / source_dir / package_name)
5✔
474
        module_name = poetry_module_name(cwd.name)
5✔
475
        ds.extend([cwd / module_name, cwd / "src" / module_name, cwd])
5✔
476
        for d in ds:
5✔
477
            init_file = d / "__init__.py"
5✔
478
            if (init_file.exists() and pattern.search(init_file.read_text("utf8"))) or (
5✔
479
                (init_file := init_file.with_name("__version__.py")).exists()
480
                and pattern.search(init_file.read_text("utf8"))
481
            ):
482
                break
5✔
483
        else:
484
            raise ParseError("Version file not found! Where are you now?")
5✔
485
        return os.path.relpath(init_file, cwd)
5✔
486

487
    def get_part(self, s: str) -> str:
5✔
488
        choices: dict[str, str] = {}
5✔
489
        for i, p in enumerate(self.PartChoices, 1):
5✔
490
            v = str(p)
5✔
491
            choices.update({str(i): v, v: v})
5✔
492
        try:
5✔
493
            return choices[s]
5✔
494
        except KeyError as e:
5✔
495
            echo(f"Invalid part: {s!r}")
5✔
496
            raise Exit(1) from e
5✔
497

498
    def gen(self) -> str:
5✔
499
        should_sync, _version = get_current_version(check_version=True)
5✔
500
        filename = self.filename
5✔
501
        echo(f"Current version(@{filename}): {_version}")
5✔
502
        if self.part:
5✔
503
            part = self.get_part(self.part)
5✔
504
        else:
505
            part = "patch"
5✔
506
            if a := input("Which one?").strip():
5✔
507
                part = self.get_part(a)
5✔
508
        self.part = part
5✔
509
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5✔
510
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
5✔
511
        if self.commit:
5✔
512
            if part != "patch":
5✔
513
                cmd += " --tag"
5✔
514
            cmd += " --commit"
5✔
515
            if self._emoji or (self._emoji is None and self.should_add_emoji()):
5✔
516
                cmd += " --message-emoji=1"
5✔
517
            if not load_bool("DONT_GIT_PUSH"):
5✔
518
                cmd += " && git push && git push --tags && git log -1"
5✔
519
        else:
520
            cmd += " --allow-dirty"
5✔
521
        if (
5✔
522
            should_sync
523
            and not self._no_sync
524
            and (sync := Project.get_sync_command(only_me=True))
525
        ):
526
            cmd = f"{sync} && " + cmd
5✔
527
        return cmd
5✔
528

529
    def run(self) -> None:
5✔
530
        super().run()
5✔
531
        if not self.commit and not self.dry:
5✔
532
            new_version = get_current_version(True)
5✔
533
            echo(new_version)
5✔
534
            if self.part != "patch":
5✔
535
                echo("You may want to pin tag by `fast tag`")
5✔
536

537

538
@cli.command()
5✔
539
def version() -> None:
5✔
540
    """Show the version of this tool"""
541
    echo("Fast Dev Cli Version: " + typer.style(__version__, fg=typer.colors.BLUE))
5✔
542
    with contextlib.suppress(FileNotFoundError, KeyError):
5✔
543
        toml_text = Project.load_toml_text()
5✔
544
        doc = tomllib.loads(toml_text)
5✔
545
        if value := doc.get("project", {}).get("version", ""):
5✔
546
            styled = typer.style(value, bold=True, fg=typer.colors.CYAN)
×
547
            if project_name := doc["project"].get("name", ""):
×
UNCOV
548
                echo(f"{project_name} version: " + styled)
×
549
            else:
UNCOV
550
                echo(f"Got Version from {TOML_FILE}: " + styled)
×
UNCOV
551
            return
×
552
        version_file = doc["tool"]["pdm"]["version"]["path"]
5✔
553
        text = Project.get_work_dir().joinpath(version_file).read_text()
5✔
554
        varname = "__version__"
5✔
555
        for line in text.splitlines():
5✔
556
            if line.strip().startswith(varname):
5✔
557
                value = line.split("=", 1)[-1].strip().strip('"').strip("'")
5✔
558
                styled = typer.style(value, bold=True)
5✔
559
                echo(f"Version value in {version_file}: " + styled)
5✔
560
                break
5✔
561

562

563
@cli.command(name="bump")
5✔
564
def bump_version(
5✔
565
    part: BumpUp.PartChoices,
566
    commit: bool = Option(
567
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
568
    ),
569
    emoji: bool | None = Option(
570
        None, "--emoji", help="Whether add emoji prefix to commit message"
571
    ),
572
    no_sync: bool = Option(
573
        False, "--no-sync", help="Do not run sync command to update version"
574
    ),
575
    dry: bool = DryOption,
576
) -> None:
577
    """Bump up version string in pyproject.toml"""
578
    if emoji is not None:
5✔
579
        emoji = _ensure_bool(emoji)
5✔
580
    return BumpUp(
5✔
581
        _ensure_bool(commit),
582
        getattr(part, "value", part),
583
        no_sync=_ensure_bool(no_sync),
584
        emoji=emoji,
585
        dry=dry,
586
    ).run()
587

588

589
def bump() -> None:
5✔
590
    part, commit = "", False
5✔
591
    if args := sys.argv[2:]:
5✔
592
        if "-c" in args or "--commit" in args:
5✔
593
            commit = True
5✔
594
        for a in args:
5✔
595
            if not a.startswith("-"):
5✔
596
                part = a
5✔
597
                break
5✔
598
    return BumpUp(commit, part, no_sync="--no-sync" in args, dry="--dry" in args).run()
5✔
599

600

601
class Project:
5✔
602
    path_depth = 5
5✔
603
    _tool: ToolName | None = None
5✔
604

605
    @staticmethod
5✔
606
    def is_poetry_v2(text: str) -> bool:
5✔
607
        return 'build-backend = "poetry' in text
5✔
608

609
    @staticmethod
5✔
610
    def get_poetry_version(command: str = "poetry") -> str:
5✔
611
        pattern = r"(\d+\.\d+\.\d+)"
5✔
612
        text = capture_cmd_output(f"{command} --version")
5✔
613
        for expr in (
5✔
614
            rf"Poetry \(version {pattern}\)",
615
            rf"Poetry.*version.*{pattern}.*\)",
616
            rf"{pattern}",
617
        ):
618
            if m := re.search(expr, text):
5✔
619
                return m.group(1)
5✔
UNCOV
620
        return ""
×
621

622
    @staticmethod
5✔
623
    def work_dir(
5✔
624
        name: str, parent: Path, depth: int, be_file: bool = False
625
    ) -> Path | None:
626
        for _ in range(depth):
5✔
627
            if (f := parent.joinpath(name)).exists():
5✔
628
                if be_file:
5✔
629
                    return f
5✔
630
                return parent
5✔
631
            parent = parent.parent
5✔
632
        return None
5✔
633

634
    @classmethod
5✔
635
    def get_work_dir(
5✔
636
        cls: type[Self],
637
        name: str = TOML_FILE,
638
        cwd: Path | None = None,
639
        allow_cwd: bool = False,
640
        be_file: bool = False,
641
    ) -> Path:
642
        cwd = cwd or Path.cwd()
5✔
643
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5✔
644
            return d
5✔
645
        if allow_cwd:
5✔
646
            return cls.get_root_dir(cwd)
5✔
647
        raise EnvError(f"{name} not found! Make sure this is a python project.")
5✔
648

649
    @classmethod
5✔
650
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
5✔
651
        toml_file = cls.get_work_dir(name, be_file=True)
5✔
652
        return toml_file.read_text("utf8")
5✔
653

654
    @classmethod
5✔
655
    def manage_by_poetry(cls: type[Self], cache: bool = False) -> bool:
5✔
656
        return cls.get_manage_tool(cache=cache) == "poetry"
5✔
657

658
    @classmethod
5✔
659
    def get_manage_tool(cls: type[Self], cache: bool = False) -> ToolName | None:
5✔
660
        if cache and cls._tool:
5✔
661
            return cls._tool
5✔
662
        try:
5✔
663
            text = cls.load_toml_text()
5✔
664
        except EnvError:
5✔
665
            return None
5✔
666
        backend = ""
5✔
667
        skip_uv = load_bool("FASTDEVCLI_SKIP_UV")
5✔
668
        with contextlib.suppress(KeyError, tomllib.TOMLDecodeError):
5✔
669
            doc = tomllib.loads(text)
5✔
670
            backend = doc["build-system"]["build-backend"]
5✔
671
            if skip_uv:
5✔
UNCOV
672
                for t in ("pdm", "poetry"):
×
UNCOV
673
                    if t in backend:
×
UNCOV
674
                        cls._tool = t
×
675
                        return cls._tool
×
676
        work_dir: Path | None = None
5✔
677
        uv_lock_exists: bool | None = None
5✔
678
        if skip_uv:
5✔
679
            for name in ("pdm", "poetry"):
×
680
                if f"[tool.{name}]" in text:
×
681
                    cls._tool = cast(ToolName, name)
×
682
                    return cls._tool
×
683
            work_dir = cls.get_work_dir(allow_cwd=True)
×
684
            for name in ("pdm", "poetry"):
×
UNCOV
685
                if Path(work_dir, f"{name}.lock").exists():
×
686
                    cls._tool = cast(ToolName, name)
×
687
                    return cls._tool
×
688
            if uv_lock_exists := Path(work_dir, "uv.lock").exists():
×
689
                # Use pdm when uv is not available for uv managed project
UNCOV
690
                cls._tool = "pdm"
×
UNCOV
691
                return cls._tool
×
UNCOV
692
            return None
×
693
        if work_dir is None:
5✔
694
            work_dir = cls.get_work_dir(allow_cwd=True)
5✔
695
        if uv_lock_exists is None:
5✔
696
            uv_lock_exists = Path(work_dir, "uv.lock").exists()
5✔
697
        if uv_lock_exists:
5✔
698
            cls._tool = "uv"
5✔
699
            return cls._tool
5✔
700
        pdm_lock_exists = Path(work_dir, "pdm.lock").exists()
5✔
701
        poetry_lock_exists = Path(work_dir, "poetry.lock").exists()
5✔
702
        match pdm_lock_exists + poetry_lock_exists:
5✔
703
            case 1:
5✔
704
                cls._tool = "pdm" if pdm_lock_exists else "poetry"
5✔
705
                return cls._tool
5✔
706
            case _ as x:
5✔
707
                if backend:
5✔
708
                    for t in ("pdm", "poetry"):
5✔
709
                        if t in backend:
5✔
710
                            cls._tool = cast(ToolName, t)
5✔
711
                            return cls._tool
5✔
712
                for name in ("pdm", "poetry"):
5✔
713
                    if f"[tool.{name}]" in text:
5✔
714
                        cls._tool = cast(ToolName, name)
5✔
715
                        return cls._tool
5✔
716
                if x == 2:
5✔
UNCOV
717
                    cls._tool = (
×
718
                        "poetry" if load_bool("FASTDEVCLI_PREFER_POETRY") else "pdm"
719
                    )
720
                    return cls._tool
1✔
721
        if "[tool.uv]" in text or load_bool("FASTDEVCLI_PREFER_uv"):
5✔
722
            cls._tool = "uv"
5✔
723
            return cls._tool
5✔
724
        # Poetry 2.0 default to not include the '[tool.poetry]' section
725
        if cls.is_poetry_v2(text):
5✔
UNCOV
726
            cls._tool = "poetry"
×
UNCOV
727
            return cls._tool
×
728
        return None
5✔
729

730
    @staticmethod
5✔
731
    def python_exec_dir() -> Path:
5✔
732
        return Path(sys.executable).parent
5✔
733

734
    @classmethod
5✔
735
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5✔
736
        root = cwd or Path.cwd()
5✔
737
        venv_parent = cls.python_exec_dir().parent.parent
5✔
738
        if root.is_relative_to(venv_parent):
5✔
739
            root = venv_parent
5✔
740
        return root
5✔
741

742
    @classmethod
5✔
743
    def is_pdm_project(cls, strict: bool = True, cache: bool = False) -> bool:
5✔
744
        if cls.get_manage_tool(cache=cache) != "pdm":
5✔
745
            return False
5✔
UNCOV
746
        if strict:
×
UNCOV
747
            lock_file = cls.get_work_dir() / "pdm.lock"
×
UNCOV
748
            return lock_file.exists()
×
UNCOV
749
        return True
×
750

751
    @classmethod
5✔
752
    def get_sync_command(
5✔
753
        cls, prod: bool = True, doc: dict | None = None, only_me: bool = False
754
    ) -> str:
755
        pdm_i = "pdm install --frozen" + " --prod" * prod
5✔
756
        if cls.is_pdm_project():
5✔
UNCOV
757
            return pdm_i
×
758
        elif cls.manage_by_poetry(cache=True):
5✔
759
            cmd = "poetry install"
5✔
760
            if prod:
5✔
761
                if doc is None:
5✔
762
                    doc = tomllib.loads(cls.load_toml_text())
5✔
763
                if doc.get("project", {}).get("dependencies") or any(
5✔
764
                    i != "python"
765
                    for i in doc.get("tool", {})
766
                    .get("poetry", {})
767
                    .get("dependencies", [])
768
                ):
769
                    cmd += " --only=main"
×
770
            return cmd
5✔
UNCOV
771
        elif cls.get_manage_tool(cache=True) == "uv":
×
UNCOV
772
            install_me = "uv pip install -e ."
×
UNCOV
773
            if doc is None:
×
774
                doc = tomllib.loads(cls.load_toml_text())
×
775
            is_distribution = (
×
776
                doc.get("tool", {}).get("pdm", {}).get("distribution") is not False
777
            )
UNCOV
778
            if only_me:
×
UNCOV
779
                return install_me if is_distribution else pdm_i
×
UNCOV
780
            cmd = "uv sync --inexact" + " --no-dev" * prod
×
UNCOV
781
            if is_distribution:
×
UNCOV
782
                cmd += f" && {install_me}"
×
UNCOV
783
        return ""
×
784

785
    @classmethod
5✔
786
    def sync_dependencies(cls, prod: bool = True) -> None:
5✔
UNCOV
787
        if cmd := cls.get_sync_command():
×
UNCOV
788
            run_and_echo(cmd)
×
789

790

791
class UpgradeDependencies(Project, DryRun):
5✔
792
    def __init__(
5✔
793
        self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
794
    ) -> None:
795
        super().__init__(_exit, dry)
5✔
796
        self._tool = tool
5✔
797

798
    class DevFlag(StrEnum):
5✔
799
        new = "[tool.poetry.group.dev.dependencies]"
5✔
800
        old = "[tool.poetry.dev-dependencies]"
5✔
801

802
    @staticmethod
5✔
803
    def parse_value(version_info: str, key: str) -> str:
5✔
804
        """Pick out the value for key in version info.
805

806
        Example::
807
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
808
            >>> UpgradeDependencies.parse_value(s, 'extras')
809
            'all'
810
            >>> UpgradeDependencies.parse_value(s, 'optional')
811
            'true'
812
            >>> UpgradeDependencies.parse_value(s, 'version')
813
            '^0.9.0'
814
        """
815
        sep = key + " = "
5✔
816
        rest = version_info.split(sep, 1)[-1].strip(" =")
5✔
817
        if rest.startswith("["):
5✔
818
            rest = rest[1:].split("]")[0]
5✔
819
        elif rest.startswith('"'):
5✔
820
            rest = rest[1:].split('"')[0]
5✔
821
        else:
822
            rest = rest.split(",")[0].split("}")[0]
5✔
823
        return rest.strip().replace('"', "")
5✔
824

825
    @staticmethod
5✔
826
    def no_need_upgrade(version_info: str, line: str) -> bool:
5✔
827
        if (v := version_info.replace(" ", "")).startswith("{url="):
5✔
828
            echo(f"No need to upgrade for: {line}")
5✔
829
            return True
5✔
830
        if (f := "version=") in v:
5✔
831
            v = v.split(f)[1].strip('"').split('"')[0]
5✔
832
        if v == "*":
5✔
833
            echo(f"Skip wildcard line: {line}")
5✔
834
            return True
5✔
835
        elif v == "[":
5✔
836
            echo(f"Skip complex dependence: {line}")
5✔
837
            return True
5✔
838
        elif v.startswith(">") or v.startswith("<") or v[0].isdigit():
5✔
839
            echo(f"Ignore bigger/smaller/equal: {line}")
5✔
840
            return True
5✔
841
        return False
5✔
842

843
    @classmethod
5✔
844
    def build_args(
5✔
845
        cls: type[Self], package_lines: list[str]
846
    ) -> tuple[list[str], dict[str, list[str]]]:
847
        args: list[str] = []  # ['typer[all]', 'fastapi']
5✔
848
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
5✔
849
        for no, line in enumerate(package_lines, 1):
5✔
850
            if (
5✔
851
                not (m := line.strip())
852
                or m.startswith("#")
853
                or m == "]"
854
                or (m.startswith("{") and m.strip(",").endswith("}"))
855
            ):
856
                continue
5✔
857
            try:
5✔
858
                package, version_info = m.split("=", 1)
5✔
859
            except ValueError as e:
5✔
860
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
5✔
861
            if (package := package.strip()).lower() == "python":
5✔
862
                continue
5✔
863
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
5✔
864
                continue
5✔
865
            if (extras_tip := "extras") in version_info:
5✔
866
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
5✔
867
            item = f'"{package}@latest"'
5✔
868
            key = None
5✔
869
            if (pf := "platform") in version_info:
5✔
870
                platform = cls.parse_value(version_info, pf)
5✔
871
                key = f"--{pf}={platform}"
5✔
872
            if (sc := "source") in version_info:
5✔
873
                source = cls.parse_value(version_info, sc)
5✔
874
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
5✔
875
            if "optional = true" in version_info:
5✔
876
                key = ("" if key is None else (key + " ")) + "--optional"
5✔
877
            if key is not None:
5✔
878
                specials[key] = specials.get(key, []) + [item]
5✔
879
            else:
880
                args.append(item)
5✔
881
        return args, specials
5✔
882

883
    @classmethod
5✔
884
    def should_with_dev(cls: type[Self]) -> bool:
5✔
885
        text = cls.load_toml_text()
5✔
886
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
887

888
    @staticmethod
5✔
889
    def parse_item(toml_str: str) -> list[str]:
5✔
890
        lines: list[str] = []
5✔
891
        for line in toml_str.splitlines():
5✔
892
            if (line := line.strip()).startswith("["):
5✔
893
                if lines:
5✔
894
                    break
5✔
895
            elif line:
5✔
896
                lines.append(line)
5✔
897
        return lines
5✔
898

899
    @classmethod
5✔
900
    def get_args(
5✔
901
        cls: type[Self], toml_text: str | None = None
902
    ) -> tuple[list[str], list[str], list[list[str]], str]:
903
        if toml_text is None:
5✔
904
            toml_text = cls.load_toml_text()
5✔
905
        main_title = "[tool.poetry.dependencies]"
5✔
906
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
5✔
907
            toml_text
908
        ):
909
            raise EnvError(
5✔
910
                f"{main_title} not found! Make sure this is a poetry project."
911
            )
912
        text = toml_text.split(main_title)[-1]
5✔
913
        dev_flag = "--group dev"
5✔
914
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
5✔
915
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
5✔
916
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
5✔
917
            dev_flag = "--dev"
5✔
918
        others: list[list[str]] = []
5✔
919
        try:
5✔
920
            main_toml, dev_toml = text.split(dev_title)
5✔
921
        except ValueError:
5✔
922
            dev_toml = ""
5✔
923
            main_toml = text
5✔
924
        mains = [] if no_main_deps else cls.parse_item(main_toml)
5✔
925
        devs = cls.parse_item(dev_toml)
5✔
926
        prod_packs, specials = cls.build_args(mains)
5✔
927
        if specials:
5✔
928
            others.extend([[k] + v for k, v in specials.items()])
5✔
929
        dev_packs, specials = cls.build_args(devs)
5✔
930
        if specials:
5✔
931
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
5✔
932
        return prod_packs, dev_packs, others, dev_flag
5✔
933

934
    @classmethod
5✔
935
    def gen_cmd(cls: type[Self]) -> str:
5✔
936
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
937
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
938

939
    @staticmethod
5✔
940
    def to_cmd(
5✔
941
        main_args: list[str],
942
        dev_args: list[str],
943
        others: list[list[str]],
944
        dev_flags: str,
945
    ) -> str:
946
        command = "poetry add "
5✔
947
        _upgrade = ""
5✔
948
        if main_args:
5✔
949
            _upgrade = command + " ".join(main_args)
5✔
950
        if dev_args:
5✔
951
            if _upgrade:
5✔
952
                _upgrade += " && "
5✔
953
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5✔
954
        for single in others:
5✔
955
            _upgrade += f" && poetry add {' '.join(single)}"
5✔
956
        return _upgrade
5✔
957

958
    def gen(self) -> str:
5✔
959
        if self._tool == "uv":
5✔
960
            up = "uv lock --upgrade --verbose"
5✔
961
            deps = "uv sync --inexact --frozen --all-groups --all-extras"
5✔
962
            return f"{up} && {deps}"
5✔
963
        elif self._tool == "pdm":
5✔
964
            return "pdm update --verbose && pdm install -G :all --frozen"
5✔
965
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
966

967

968
@cli.command()
5✔
969
def upgrade(
5✔
970
    tool: str = ToolOption,
971
    dry: bool = DryOption,
972
) -> None:
973
    """Upgrade dependencies in pyproject.toml to latest versions"""
974
    if not (tool := _ensure_str(tool)) or tool == ToolOption.default:
5✔
975
        tool = Project.get_manage_tool() or "uv"
5✔
976
    if tool in get_args(ToolName):
5✔
977
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
5✔
978
    else:
979
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
5✔
980
        raise typer.Exit(1)
5✔
981

982

983
class GitTag(DryRun):
5✔
984
    def __init__(self, message: str, dry: bool, no_sync: bool = False) -> None:
5✔
985
        self.message = message
5✔
986
        self._no_sync = no_sync
5✔
987
        super().__init__(dry=dry)
5✔
988

989
    @staticmethod
5✔
990
    def has_v_prefix() -> bool:
5✔
991
        return "v" in capture_cmd_output("git tag")
5✔
992

993
    def should_push(self) -> bool:
5✔
994
        return "git push" in self.git_status
5✔
995

996
    def gen(self) -> str:
5✔
997
        should_sync, _version = get_current_version(verbose=False, check_version=True)
5✔
998
        if self.has_v_prefix():
5✔
999
            # Add `v` at prefix to compare with bumpversion tool
1000
            _version = "v" + _version
5✔
1001
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
5✔
1002
        if self.should_push():
5✔
1003
            cmd += " && git push"
5✔
1004
        if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
5✔
UNCOV
1005
            cmd = f"{sync} && " + cmd
×
1006
        return cmd
5✔
1007

1008
    @cached_property
5✔
1009
    def git_status(self) -> str:
5✔
1010
        return capture_cmd_output("git status")
5✔
1011

1012
    def mark_tag(self) -> bool:
5✔
1013
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5✔
1014
            "无文件要提交,干净的工作区" not in self.git_status
1015
        ):
1016
            run_and_echo("git status")
5✔
1017
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5✔
1018
            return False
5✔
1019
        return bool(super().run())
5✔
1020

1021
    def run(self) -> None:
5✔
1022
        if self.mark_tag() and not self.dry:
5✔
1023
            echo("You may want to publish package:\n poetry publish --build")
5✔
1024

1025

1026
@cli.command()
5✔
1027
def tag(
5✔
1028
    message: str = Option("", "-m", "--message"),
1029
    no_sync: bool = Option(
1030
        False, "--no-sync", help="Do not run sync command to update version"
1031
    ),
1032
    dry: bool = DryOption,
1033
) -> None:
1034
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
1035
    GitTag(message, dry=dry, no_sync=_ensure_bool(no_sync)).run()
5✔
1036

1037

1038
class LintCode(DryRun):
5✔
1039
    def __init__(
5✔
1040
        self,
1041
        args: list[str] | str | None,
1042
        check_only: bool = False,
1043
        _exit: bool = False,
1044
        dry: bool = False,
1045
        bandit: bool = False,
1046
        skip_mypy: bool = False,
1047
        dmypy: bool = False,
1048
        tool: str = ToolOption.default,
1049
        prefix: bool = False,
1050
    ) -> None:
1051
        self.args = args
5✔
1052
        self.check_only = check_only
5✔
1053
        self._bandit = bandit
5✔
1054
        self._skip_mypy = skip_mypy
5✔
1055
        self._use_dmypy = dmypy
5✔
1056
        self._tool = tool
5✔
1057
        self._prefix = prefix
5✔
1058
        super().__init__(_exit, dry)
5✔
1059

1060
    @staticmethod
5✔
1061
    def check_lint_tool_installed() -> bool:
5✔
1062
        try:
5✔
1063
            return check_call("ruff --version")
5✔
UNCOV
1064
        except FileNotFoundError:
×
1065
            # Windows may raise FileNotFoundError when ruff not installed
UNCOV
1066
            return False
×
1067

1068
    @staticmethod
5✔
1069
    def missing_mypy_exec() -> bool:
5✔
1070
        return shutil.which("mypy") is None
5✔
1071

1072
    @staticmethod
5✔
1073
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
5✔
1074
        return (
5✔
1075
            paths == "."
1076
            and any(t.startswith("mypy") for t in tools)
1077
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
1078
        )
1079

1080
    @staticmethod
5✔
1081
    def get_package_name() -> str:
5✔
1082
        root = Project.get_work_dir(allow_cwd=True)
5✔
1083
        module_name = root.name.replace("-", "_").replace(" ", "_")
5✔
1084
        package_maybe = (module_name, "src")
5✔
1085
        for name in package_maybe:
5✔
1086
            if root.joinpath(name).is_dir():
5✔
1087
                return name
5✔
1088
        return "."
5✔
1089

1090
    @classmethod
5✔
1091
    def to_cmd(
5✔
1092
        cls: type[Self],
1093
        paths: str = ".",
1094
        check_only: bool = False,
1095
        bandit: bool = False,
1096
        skip_mypy: bool = False,
1097
        use_dmypy: bool = False,
1098
        tool: str = ToolOption.default,
1099
        with_prefix: bool = False,
1100
    ) -> str:
1101
        if paths != "." and all(i.endswith(".html") for i in paths.split()):
5✔
1102
            return f"prettier -w {paths}"
5✔
1103
        cmd = ""
5✔
1104
        tools = ["ruff format", "ruff check --extend-select=I,B,SIM --fix", "mypy"]
5✔
1105
        if check_only:
5✔
1106
            tools[0] += " --check"
5✔
1107
        if check_only or load_bool("NO_FIX"):
5✔
1108
            tools[1] = tools[1].replace(" --fix", "")
5✔
1109
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
5✔
1110
            # Sometimes mypy is too slow
1111
            tools = tools[:-1]
5✔
1112
        elif load_bool("IGNORE_MISSING_IMPORTS"):
5✔
1113
            tools[-1] += " --ignore-missing-imports"
5✔
1114
        lint_them = " && ".join(
5✔
1115
            "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
1116
        )
1117
        if ruff_exists := cls.check_lint_tool_installed():
5✔
1118
            # `ruff <command>` get the same result with `pdm run ruff <command>`
1119
            # While `mypy .`(installed global and env not activated),
1120
            #   does not the same as `pdm run mypy .`
1121
            lint_them = " && ".join(
5✔
1122
                ("" if tool.startswith("ruff") else "{0}")
1123
                + (
1124
                    "{%d} {1}" % i  # noqa: UP031
1125
                )
1126
                for i, tool in enumerate(tools, 2)
1127
            )
1128
        prefix = ""
5✔
1129
        should_run_by_tool = with_prefix
5✔
1130
        if not should_run_by_tool:
5✔
1131
            if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(
5✔
1132
                ".local/bin"
1133
            ):  # Virtual environment activated and fast-dev-cli is installed in it
1134
                if not ruff_exists:
5✔
1135
                    should_run_by_tool = True
5✔
1136
                    command = "pipx install ruff"
5✔
1137
                    if shutil.which("pipx") is None:
5✔
UNCOV
1138
                        ensure_pipx = "pip install --user pipx\n  pipx ensurepath\n  "
×
1139
                        command = ensure_pipx + command
×
1140
                    yellow_warn(
5✔
1141
                        "You may need to run the following command"
1142
                        f" to install ruff:\n\n  {command}\n"
1143
                    )
1144
                elif "mypy" in str(tools) and cls.missing_mypy_exec():
5✔
1145
                    should_run_by_tool = True
5✔
1146
                    if check_call('python -c "import fast_dev_cli"'):
5✔
1147
                        command = 'python -m pip install -U "fast-dev-cli"'
5✔
1148
                        yellow_warn(
5✔
1149
                            "You may need to run the following command"
1150
                            f" to install lint tools:\n\n  {command}\n"
1151
                        )
UNCOV
1152
            elif tool == ToolOption.default:
×
UNCOV
1153
                root = Project.get_work_dir(allow_cwd=True)
×
UNCOV
1154
                if py := shutil.which("python"):
×
1155
                    try:
×
1156
                        Path(py).relative_to(root)
×
1157
                    except ValueError:
×
1158
                        # Virtual environment not activated
UNCOV
1159
                        should_run_by_tool = True
×
1160
            else:
UNCOV
1161
                should_run_by_tool = True
×
1162
        if should_run_by_tool and tool:
5✔
1163
            if tool == ToolOption.default:
5✔
1164
                tool = Project.get_manage_tool() or ""
5✔
1165
            if tool:
5✔
1166
                prefix = tool + " run "
5✔
1167
                if tool == "uv":
5✔
UNCOV
1168
                    if is_windows():
×
UNCOV
1169
                        prefix += "--no-sync "
×
UNCOV
1170
                    elif Path(bin_dir := ".venv/bin/").exists():
×
UNCOV
1171
                        prefix = bin_dir
×
1172
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
5✔
1173
            tools[-1] = "dmypy run"
5✔
1174
        cmd += lint_them.format(prefix, paths, *tools)
5✔
1175
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
5✔
1176
            command = prefix + "bandit"
5✔
1177
            if Path("pyproject.toml").exists():
5✔
1178
                toml_text = Project.load_toml_text()
5✔
1179
                if "[tool.bandit" in toml_text:
5✔
1180
                    command += " -c pyproject.toml"
5✔
1181
            if paths == "." and " -c " not in command:
5✔
1182
                paths = cls.get_package_name()
5✔
1183
            command += f" -r {paths}"
5✔
1184
            cmd += " && " + command
5✔
1185
        return cmd
5✔
1186

1187
    def gen(self) -> str:
5✔
1188
        paths = "."
5✔
1189
        if args := self.args:
5✔
1190
            ps = args.split() if isinstance(args, str) else [str(i) for i in args]
5✔
1191
            if len(ps) == 1:
5✔
1192
                paths = ps[0]
5✔
1193
                if (
5✔
1194
                    paths != "."
1195
                    # `Path("a.").suffix` got "." in py3.14 and got "" with py<3.14
1196
                    and (p := Path(paths)).suffix in ("", ".")
1197
                    and not p.exists()
1198
                ):
1199
                    # e.g.:
1200
                    # stem -> stem.py
1201
                    # me. -> me.py
UNCOV
1202
                    if paths.endswith("."):
×
UNCOV
1203
                        p = p.with_name(paths[:-1])
×
UNCOV
1204
                    for suffix in (".py", ".html"):
×
UNCOV
1205
                        p = p.with_suffix(suffix)
×
UNCOV
1206
                        if p.exists():
×
UNCOV
1207
                            paths = p.name
×
UNCOV
1208
                            break
×
1209
            else:
UNCOV
1210
                paths = " ".join(ps)
×
1211
        return self.to_cmd(
5✔
1212
            paths,
1213
            self.check_only,
1214
            self._bandit,
1215
            self._skip_mypy,
1216
            self._use_dmypy,
1217
            tool=self._tool,
1218
            with_prefix=self._prefix,
1219
        )
1220

1221

1222
def parse_files(args: list[str] | tuple[str, ...]) -> list[str]:
5✔
1223
    return [i for i in args if not i.startswith("-")]
5✔
1224

1225

1226
def lint(
5✔
1227
    files: list[str] | str | None = None,
1228
    dry: bool = False,
1229
    bandit: bool = False,
1230
    skip_mypy: bool = False,
1231
    dmypy: bool = False,
1232
    tool: str = ToolOption.default,
1233
    prefix: bool = False,
1234
) -> None:
1235
    if files is None:
5✔
1236
        files = parse_files(sys.argv[1:])
5✔
1237
    if files and files[0] == "lint":
5✔
1238
        files = files[1:]
5✔
1239
    LintCode(
5✔
1240
        files,
1241
        dry=dry,
1242
        skip_mypy=skip_mypy,
1243
        bandit=bandit,
1244
        dmypy=dmypy,
1245
        tool=tool,
1246
        prefix=prefix,
1247
    ).run()
1248

1249

1250
def check(
5✔
1251
    files: list[str] | str | None = None,
1252
    dry: bool = False,
1253
    bandit: bool = False,
1254
    skip_mypy: bool = False,
1255
    dmypy: bool = False,
1256
    tool: str = ToolOption.default,
1257
) -> None:
1258
    LintCode(
5✔
1259
        files,
1260
        check_only=True,
1261
        _exit=True,
1262
        dry=dry,
1263
        bandit=bandit,
1264
        skip_mypy=skip_mypy,
1265
        dmypy=dmypy,
1266
        tool=tool,
1267
    ).run()
1268

1269

1270
@cli.command(name="lint")
5✔
1271
def make_style(
5✔
1272
    files: list[str] | None = typer.Argument(default=None),  # noqa:B008
1273
    check_only: bool = Option(False, "--check-only", "-c"),
1274
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1275
    prefix: bool = Option(
1276
        False,
1277
        "--prefix",
1278
        help="Run lint command with tool prefix, e.g.: pdm run ruff ...",
1279
    ),
1280
    skip_mypy: bool = Option(False, "--skip-mypy"),
1281
    use_dmypy: bool = Option(
1282
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
1283
    ),
1284
    tool: str = ToolOption,
1285
    dry: bool = DryOption,
1286
) -> None:
1287
    """Run: ruff check/format to reformat code and then mypy to check"""
1288
    if getattr(files, "default", files) is None:
5✔
1289
        files = ["."]
5✔
1290
    elif isinstance(files, str):
5✔
1291
        files = [files]
5✔
1292
    skip = _ensure_bool(skip_mypy)
5✔
1293
    dmypy = _ensure_bool(use_dmypy)
5✔
1294
    bandit = _ensure_bool(bandit)
5✔
1295
    prefix = _ensure_bool(prefix)
5✔
1296
    tool = _ensure_str(tool)
5✔
1297
    kwargs = {"dry": dry, "skip_mypy": skip, "dmypy": dmypy, "bandit": bandit}
5✔
1298
    if _ensure_bool(check_only):
5✔
1299
        check(files, tool=tool, **kwargs)
5✔
1300
    else:
1301
        lint(files, prefix=prefix, tool=tool, **kwargs)
5✔
1302

1303

1304
@cli.command(name="check")
5✔
1305
def only_check(
5✔
1306
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1307
    skip_mypy: bool = Option(False, "--skip-mypy"),
1308
    dry: bool = DryOption,
1309
) -> None:
1310
    """Check code style without reformat"""
1311
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
5✔
1312

1313

1314
class Sync(DryRun):
5✔
1315
    def __init__(
5✔
1316
        self, filename: str, extras: str, save: bool, dry: bool = False
1317
    ) -> None:
1318
        self.filename = filename
5✔
1319
        self.extras = extras
5✔
1320
        self._save = save
5✔
1321
        super().__init__(dry=dry)
5✔
1322

1323
    def gen(self) -> str:
5✔
1324
        extras, save = self.extras, self._save
5✔
1325
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
1326
        if not (tool := Project.get_manage_tool()):
5✔
1327
            if should_remove or not is_venv():
5✔
1328
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
1329
            return f"python -m pip install -r {self.filename}"
5✔
1330
        prefix = "" if is_venv() else f"{tool} run "
5✔
1331
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
1332
        export_cmd = "uv export --no-hashes --all-extras --frozen"
5✔
1333
        if tool in ("poetry", "pdm"):
5✔
1334
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
1335
            if tool == "poetry":
5✔
1336
                ensure_pip = ""
5✔
1337
                if not UpgradeDependencies.should_with_dev():
5✔
1338
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
1339
                if extras and isinstance(extras, str | list):
5✔
1340
                    export_cmd += f" --{extras=}".replace("'", '"')
5✔
1341
            elif check_call(prefix + "python -m pip --version"):
5✔
1342
                ensure_pip = ""
5✔
1343
        elif check_call(prefix + "python -m pip --version"):
5✔
1344
            ensure_pip = ""
5✔
1345
        install_cmd = (
5✔
1346
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
1347
        )
1348
        if should_remove and not save:
5✔
1349
            install_cmd += " && rm -f {0}"
5✔
1350
        return install_cmd.format(self.filename, prefix, export_cmd)
5✔
1351

1352

1353
@cli.command()
5✔
1354
def sync(
5✔
1355
    filename: str = "dev_requirements.txt",
1356
    extras: str = Option("", "--extras", "-E"),
1357
    save: bool = Option(
1358
        False, "--save", "-s", help="Whether save the requirement file"
1359
    ),
1360
    dry: bool = DryOption,
1361
) -> None:
1362
    """Export dependencies by poetry to a txt file then install by pip."""
1363
    Sync(filename, extras, save, dry=dry).run()
5✔
1364

1365

1366
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
1367
    for name in ("test.sh", "test.py"):
5✔
1368
        if (file := path / name).exists():
5✔
1369
            return file
5✔
1370
    return None
5✔
1371

1372

1373
def test(dry: bool, ignore_script: bool = False) -> None:
5✔
1374
    cwd = Path.cwd()
5✔
1375
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
1376
    script_dir = root / "scripts"
5✔
1377
    if not _ensure_bool(ignore_script) and (
5✔
1378
        test_script := _should_run_test_script(script_dir)
1379
    ):
1380
        cmd = test_script.relative_to(root).as_posix()
5✔
1381
        if test_script.suffix == ".py":
5✔
1382
            cmd = "python " + cmd
5✔
1383
        if cwd != root:
5✔
1384
            cmd = f"cd {root} && " + cmd
5✔
1385
    else:
1386
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
1387
        if not is_venv() or not check_call("coverage --version"):
5✔
1388
            sep = " && "
5✔
1389
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
1390
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
1391
    exit_if_run_failed(cmd, dry=dry)
5✔
1392

1393

1394
@cli.command(name="test")
5✔
1395
def coverage_test(
5✔
1396
    dry: bool = DryOption,
1397
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1398
) -> None:
1399
    """Run unittest by pytest and report coverage"""
1400
    return test(dry, ignore_script)
5✔
1401

1402

1403
class Publish:
5✔
1404
    class CommandEnum(StrEnum):
5✔
1405
        poetry = "poetry publish --build"
5✔
1406
        pdm = "pdm publish"
5✔
1407
        uv = "uv build && uv publish"
5✔
1408
        twine = "python -m build && twine upload"
5✔
1409

1410
    @classmethod
5✔
1411
    def gen(cls) -> str:
5✔
1412
        if tool := Project.get_manage_tool():
5✔
1413
            return cls.CommandEnum[tool]
5✔
1414
        return cls.CommandEnum.twine
5✔
1415

1416

1417
@cli.command()
5✔
1418
def upload(
5✔
1419
    dry: bool = DryOption,
1420
) -> None:
1421
    """Shortcut for package publish"""
1422
    cmd = Publish.gen()
5✔
1423
    exit_if_run_failed(cmd, dry=dry)
5✔
1424

1425

1426
def dev(
5✔
1427
    port: int | None | OptionInfo,
1428
    host: str | None | OptionInfo,
1429
    file: str | None | ArgumentInfo = None,
1430
    dry: bool = False,
1431
) -> None:
1432
    cmd = "fastapi dev"
5✔
1433
    no_port_yet = True
5✔
1434
    if file is not None:
5✔
1435
        try:
5✔
1436
            port = int(str(file))
5✔
1437
        except ValueError:
5✔
1438
            cmd += f" {file}"
5✔
1439
        else:
1440
            if port != 8000:
5✔
1441
                cmd += f" --port={port}"
5✔
1442
                no_port_yet = False
5✔
1443
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
1444
        cmd += f" --port={port}"
5✔
1445
    if (host := getattr(host, "default", host)) and host not in (
5✔
1446
        "localhost",
1447
        "127.0.0.1",
1448
    ):
1449
        cmd += f" --host={host}"
5✔
1450
    exit_if_run_failed(cmd, dry=dry)
5✔
1451

1452

1453
@cli.command(name="dev")
5✔
1454
def runserver(
5✔
1455
    file_or_port: str | None = typer.Argument(default=None),
1456
    port: int | None = Option(None, "-p", "--port"),
1457
    host: str | None = Option(None, "-h", "--host"),
1458
    dry: bool = DryOption,
1459
) -> None:
1460
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1461
    if getattr(file_or_port, "default", file_or_port):
5✔
1462
        dev(port, host, file=file_or_port, dry=dry)
5✔
1463
    else:
1464
        dev(port, host, dry=dry)
5✔
1465

1466

1467
@cli.command(name="exec")
5✔
1468
def run_by_subprocess(cmd: str, dry: bool = DryOption) -> None:
5✔
1469
    """Run cmd by subprocess, auto set shell=True when cmd contains '|>'"""
1470
    try:
5✔
1471
        rc = run_and_echo(cmd, verbose=True, dry=_ensure_bool(dry))
5✔
1472
    except FileNotFoundError as e:
5✔
1473
        command = cmd.split()[0]
5✔
1474
        if e.filename == command or (
5✔
1475
            e.filename is None and "系统找不到指定的文件" in str(e)
1476
        ):
1477
            echo(f"Command not found: {command}")
5✔
1478
            raise Exit(1) from None
5✔
UNCOV
1479
        raise e
×
1480
    else:
1481
        if rc:
5✔
1482
            raise Exit(rc)
5✔
1483

1484

1485
class MakeDeps(DryRun):
5✔
1486
    def __init__(
5✔
1487
        self,
1488
        tool: str,
1489
        prod: bool = False,
1490
        dry: bool = False,
1491
        active: bool = True,
1492
        inexact: bool = True,
1493
    ) -> None:
1494
        self._tool = tool
5✔
1495
        self._prod = prod
5✔
1496
        self._active = active
5✔
1497
        self._inexact = inexact
5✔
1498
        super().__init__(dry=dry)
5✔
1499

1500
    def should_ensure_pip(self) -> bool:
5✔
1501
        return True
5✔
1502

1503
    def should_upgrade_pip(self) -> bool:
5✔
UNCOV
1504
        return True
×
1505

1506
    def get_groups(self) -> list[str]:
5✔
1507
        if self._prod:
5✔
1508
            return []
5✔
1509
        return ["dev"]
5✔
1510

1511
    def gen(self) -> str:
5✔
1512
        if self._tool == "pdm":
5✔
1513
            return "pdm install --frozen " + ("--prod" if self._prod else "-G :all")
5✔
1514
        elif self._tool == "uv":
5✔
1515
            uv_sync = "uv sync" + " --inexact" * self._inexact
5✔
1516
            if self._active:
5✔
1517
                uv_sync += " --active"
5✔
1518
            return uv_sync + ("" if self._prod else " --all-extras --all-groups")
5✔
1519
        elif self._tool == "poetry":
5✔
1520
            return "poetry install " + (
5✔
1521
                "--only=main" if self._prod else "--all-extras --all-groups"
1522
            )
1523
        else:
1524
            cmd = "python -m pip install -e ."
5✔
1525
            if gs := self.get_groups():
5✔
1526
                cmd += " " + " ".join(f"--group {g}" for g in gs)
5✔
1527
            upgrade = "python -m pip install --upgrade pip"
5✔
1528
            if self.should_ensure_pip():
5✔
1529
                cmd = f"python -m ensurepip && {upgrade} && {cmd}"
5✔
UNCOV
1530
            elif self.should_upgrade_pip():
×
UNCOV
1531
                cmd = "{upgrade} && {cmd}"
×
1532
            return cmd
5✔
1533

1534

1535
@cli.command(name="deps")
5✔
1536
def make_deps(
5✔
1537
    prod: bool = Option(
1538
        False,
1539
        "--prod",
1540
        help="Only instead production dependencies.",
1541
    ),
1542
    tool: str = ToolOption,
1543
    use_uv: bool = Option(False, "--uv", help="Use `uv` to install deps"),
1544
    use_pdm: bool = Option(False, "--pdm", help="Use `pdm` to install deps"),
1545
    use_pip: bool = Option(False, "--pip", help="Use `pip` to install deps"),
1546
    use_poetry: bool = Option(False, "--poetry", help="Use `poetry` to install deps"),
1547
    active: bool = Option(
1548
        True, help="Add `--active` to uv sync command(Only work for uv project)"
1549
    ),
1550
    inexact: bool = Option(
1551
        True, help="Add `--inexact` to uv sync command(Only work for uv project)"
1552
    ),
1553
    dry: bool = DryOption,
1554
) -> None:
1555
    """Run: ruff check/format to reformat code and then mypy to check"""
UNCOV
1556
    if use_uv + use_pdm + use_pip + use_poetry > 1:
×
UNCOV
1557
        raise UsageError("`--uv/--pdm/--pip/--poetry` can only choose one!")
×
UNCOV
1558
    if use_uv:
×
UNCOV
1559
        tool = "uv"
×
UNCOV
1560
    elif use_pdm:
×
UNCOV
1561
        tool = "pdm"
×
UNCOV
1562
    elif use_pip:
×
UNCOV
1563
        tool = "pip"
×
UNCOV
1564
    elif use_poetry:
×
UNCOV
1565
        tool = "poetry"
×
UNCOV
1566
    elif tool == ToolOption.default:
×
UNCOV
1567
        tool = Project.get_manage_tool(cache=True) or "pip"
×
UNCOV
1568
    MakeDeps(tool, prod, active=active, inexact=inexact, dry=dry).run()
×
1569

1570

1571
class UvPypi(DryRun):
5✔
1572
    PYPI = "https://pypi.org/simple"
5✔
1573
    HOST = "https://files.pythonhosted.org"
5✔
1574

1575
    def __init__(self, lock_file: Path, dry: bool, verbose: bool, quiet: bool) -> None:
5✔
1576
        super().__init__(dry=dry)
5✔
1577
        self.lock_file = lock_file
5✔
1578
        self._verbose = _ensure_bool(verbose)
5✔
1579
        self._quiet = _ensure_bool(quiet)
5✔
1580

1581
    def run(self) -> None:
5✔
1582
        try:
5✔
1583
            rc = self.update_lock(self.lock_file, self._verbose, self._quiet)
5✔
UNCOV
1584
        except ValueError as e:
×
UNCOV
1585
            secho(str(e), fg=typer.colors.RED)
×
UNCOV
1586
            raise Exit(1) from e
×
1587
        else:
1588
            if rc != 0:
5✔
1589
                raise Exit(rc)
5✔
1590

1591
    @classmethod
5✔
1592
    def update_lock(cls, p: Path, verbose: bool, quiet: bool) -> int:
5✔
1593
        text = p.read_text("utf-8")
5✔
1594
        registry_pattern = r'(registry = ")(.*?)"'
5✔
1595
        replace_registry = functools.partial(
5✔
1596
            re.sub, registry_pattern, rf'\1{cls.PYPI}"'
1597
        )
1598
        registry_urls = {i[1] for i in re.findall(registry_pattern, text)}
5✔
1599
        download_pattern = r'(url = ")(https?://.*?)(/packages/.*?\.)(gz|whl)"'
5✔
1600
        replace_host = functools.partial(
5✔
1601
            re.sub, download_pattern, rf'\1{cls.HOST}\3\4"'
1602
        )
1603
        download_hosts = {i[1] for i in re.findall(download_pattern, text)}
5✔
1604
        if not registry_urls:
5✔
UNCOV
1605
            raise ValueError(f"Failed to find pattern {registry_pattern!r} in {p}")
×
1606
        if len(registry_urls) == 1:
5✔
1607
            current_registry = registry_urls.pop()
5✔
1608
            if current_registry == cls.PYPI:
5✔
1609
                if download_hosts == {cls.HOST}:
5✔
1610
                    if verbose:
5✔
UNCOV
1611
                        echo(f"Registry of {p} is {cls.PYPI}, no need to change.")
×
1612
                    return 0
5✔
1613
            else:
1614
                text = replace_registry(text)
5✔
1615
                if verbose:
5✔
1616
                    echo(f"{current_registry} --> {cls.PYPI}")
×
1617
        else:
1618
            # TODO: ask each one to confirm replace
1619
            text = replace_registry(text)
×
1620
            if verbose:
×
1621
                for current_registry in sorted(registry_urls):
×
UNCOV
1622
                    echo(f"{current_registry} --> {cls.PYPI}")
×
1623
        if len(download_hosts) == 1:
5✔
1624
            current_host = download_hosts.pop()
5✔
1625
            if current_host != cls.HOST:
5✔
1626
                text = replace_host(text)
5✔
1627
                if verbose:
5✔
UNCOV
1628
                    print(current_host, "-->", cls.HOST)
×
UNCOV
1629
        elif download_hosts:
×
1630
            # TODO: ask each one to confirm replace
UNCOV
1631
            text = replace_host(text)
×
UNCOV
1632
            if verbose:
×
UNCOV
1633
                for current_host in sorted(download_hosts):
×
UNCOV
1634
                    echo(f"{current_host} --> {cls.HOST}")
×
1635
        size = p.write_text(text, encoding="utf-8")
5✔
1636
        if verbose:
5✔
UNCOV
1637
            echo(f"Updated {p} with {size} bytes.")
×
1638
        if quiet:
5✔
1639
            return 0
5✔
1640
        return 1
5✔
1641

1642

1643
@cli.command()
5✔
1644
def pypi(
5✔
1645
    file: str | None = typer.Argument(default=None),
1646
    dry: bool = DryOption,
1647
    verbose: bool = False,
1648
    quiet: bool = False,
1649
) -> None:
1650
    """Change registry of uv.lock to be pypi.org"""
1651
    if not (p := Path(_ensure_str(file) or "uv.lock")).exists() and not (
5✔
1652
        (p := Project.get_work_dir() / p.name).exists()
1653
    ):
1654
        yellow_warn(f"{p.name!r} not found!")
5✔
1655
        return
5✔
1656
    UvPypi(p, dry, verbose, quiet).run()
5✔
1657

1658

1659
def version_callback(value: bool) -> None:
5✔
1660
    if value:
5✔
1661
        echo("Fast Dev Cli Version: " + typer.style(__version__, bold=True))
5✔
1662
        raise Exit()
5✔
1663

1664

1665
@cli.callback()
1666
def common(
1667
    version: bool = Option(
1668
        None,
1669
        "--version",
1670
        "-V",
1671
        callback=version_callback,
1672
        is_eager=True,
1673
        help="Show the version of this tool",
1674
    ),
1675
) -> None: ...
1676

1677

1678
def main() -> None:
5✔
1679
    cli()
5✔
1680

1681

1682
if __name__ == "__main__":  # pragma: no cover
1683
    main()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc