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

waketzheng / fast-dev-cli / 30733608613

02 Aug 2026 05:08AM UTC coverage: 83.297% (-0.7%) from 84.024%
30733608613

push

github

waketzheng
fix: fast dev not work as expected when justfile in parent dir

1 of 14 new or added lines in 1 file covered. (7.14%)

1 existing line in 1 file now uncovered.

1137 of 1365 relevant lines covered (83.3%)

4.16 hits per line

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

83.28
/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, Annotated, Any, Literal, cast, get_args, overload
5 ✔
16

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

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

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

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

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

35
    import tomli as tomllib
36

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

40

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

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

55

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

59

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

63

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

67

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

71

72
def poetry_module_name(name: str) -> str:
5 ✔
73
    """Get module name that generated by `poetry new`"""
74
    try:
5 ✔
75
        from packaging.utils import canonicalize_name
5 ✔
76
    except ImportError:
×
77
        module_name = re.sub(r"[-_.]+", "-", name)
×
78
    else:
79
        module_name = canonicalize_name(name)
5 ✔
80
    return module_name.replace("-", "_").replace(" ", "_")
5 ✔
81

82

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

94

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

99

100
@functools.cache
5 ✔
101
def prefer_uv_tool() -> bool:
5 ✔
102
    if shutil.which("uv") is None:
5 ✔
103
        return False
×
104
    cmd = "uv tool list"
5 ✔
105
    return Shell(cmd).capture_output() != "No tools installed"
5 ✔
106

107

108
def yellow_warn(msg: str) -> None:
5 ✔
109
    if is_windows() and (encoding := sys.stdout.encoding) != "utf-8":
5 ✔
110
        msg = msg.encode(encoding, errors="ignore").decode(encoding)
×
111
    secho(msg, fg="yellow")
5 ✔
112

113

114
def load_bool(name: str, default: bool = False, *, verbose: bool = True) -> bool:
5 ✔
115
    if not (v := os.getenv(name)):
5 ✔
116
        return default
5 ✔
117
    if (b := _convert_bool(v)) is not None:
5 ✔
118
        return b
5 ✔
119
    if verbose:
5 ✔
120
        secho(f"WARNING: can not convert value({v!r}) of {name} to bool!")
5 ✔
121
    return default
5 ✔
122

123

124
def _convert_bool(v: str) -> bool | None:
5 ✔
125
    if (lower := v.lower()) in ("0", "false", "f", "off", "no", "n"):
5 ✔
126
        return False
5 ✔
127
    elif lower in ("1", "true", "t", "on", "yes", "y"):
5 ✔
128
        return True
5 ✔
129
    return None
5 ✔
130

131

132
def is_venv() -> bool:
5 ✔
133
    """Whether in a virtual environment(also work for poetry)"""
134
    return hasattr(sys, "real_prefix") or (
5 ✔
135
        hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
136
    )
137

138

139
class Shell:
5 ✔
140
    def __init__(self, cmd: list[str] | str, **kw: Any) -> None:
5 ✔
141
        self._cmd = cmd
5 ✔
142
        self._kw = kw
5 ✔
143

144
    @staticmethod
5 ✔
145
    def run_by_subprocess(
5 ✔
146
        cmd: list[str] | str, **kw: Any
147
    ) -> subprocess.CompletedProcess[str]:
148
        if isinstance(cmd, str):
5 ✔
149
            kw.setdefault("shell", True)
5 ✔
150
        check = kw.pop("check", False)
5 ✔
151
        return subprocess.run(cmd, check=check, **kw)  # nosec:B603
5 ✔
152

153
    @property
5 ✔
154
    def command(self) -> list[str] | str:
5 ✔
155
        command: list[str] | str = self._cmd
5 ✔
156
        if isinstance(command, str):
5 ✔
157
            cs = shlex.split(command)
5 ✔
158
            if "shell" not in self._kw and not (set(self._cmd) & {"|", ">", "&"}):
5 ✔
159
                command = self.expand_user(cs)
5 ✔
160
            elif any(i.startswith("~") for i in cs):
5 ✔
161
                command = re.sub(r" ~", " " + os.path.expanduser("~"), command)
×
162
        else:
163
            command = self.expand_user(command)
5 ✔
164
        return command
5 ✔
165

166
    @staticmethod
5 ✔
167
    def expand_user(cs: list[str]) -> list[str]:
5 ✔
168
        if cs[0] == "echo":
5 ✔
169
            return cs
5 ✔
170
        for i, c in enumerate(cs):
5 ✔
171
            if c.startswith("~"):
5 ✔
172
                cs[i] = os.path.expanduser(c)
×
173
        return cs
5 ✔
174

175
    def _run(self) -> subprocess.CompletedProcess[str]:
5 ✔
176
        return self.run_by_subprocess(self.command, **self._kw)
5 ✔
177

178
    def run(self, verbose: bool = False, dry: bool = False) -> int:
5 ✔
179
        if verbose:
5 ✔
180
            echo(f"--> {self._cmd}")
5 ✔
181
        if dry:
5 ✔
182
            return 0
5 ✔
183
        return self._run().returncode
5 ✔
184

185
    def check_call(self) -> bool:
5 ✔
186
        self._kw.update(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
5 ✔
187
        try:
5 ✔
188
            return self.run() == 0
5 ✔
189
        except FileNotFoundError:
×
190
            return False
×
191

192
    def capture_output(self, raises: bool = False) -> str:
5 ✔
193
        self._kw.update(capture_output=True, encoding="utf-8")
5 ✔
194
        r = self._run()
5 ✔
195
        if raises and r.returncode != 0:
5 ✔
196
            raise ShellCommandError(r.stderr)
5 ✔
197
        return (r.stdout or r.stderr or "").strip()
5 ✔
198

199
    def finish(
5 ✔
200
        self, env: dict[str, str] | None = None, _exit: bool = False, dry: bool = False
201
    ) -> subprocess.CompletedProcess[str]:
202
        self.run(verbose=True, dry=True)
5 ✔
203
        if _ensure_bool(dry):
5 ✔
204
            return subprocess.CompletedProcess("", 0)
5 ✔
205
        if env is not None:
5 ✔
206
            self._kw["env"] = {**os.environ, **env}
5 ✔
207
        r = self._run()
5 ✔
208
        if rc := r.returncode:
5 ✔
209
            if _exit:
5 ✔
210
                sys.exit(rc)
5 ✔
211
            raise Exit(rc)
5 ✔
212
        return r
5 ✔
213

214

215
def run_and_echo(
5 ✔
216
    cmd: str, *, dry: bool = False, verbose: bool = True, **kw: Any
217
) -> int:
218
    """Run shell command with subprocess and print it"""
219
    return Shell(cmd, **kw).run(verbose=verbose, dry=dry)
5 ✔
220

221

222
def check_call(cmd: str) -> bool:
5 ✔
223
    return Shell(cmd).check_call()
5 ✔
224

225

226
def capture_cmd_output(
5 ✔
227
    command: list[str] | str, *, raises: bool = False, **kw: Any
228
) -> str:
229
    return Shell(command, **kw).capture_output(raises=raises)
5 ✔
230

231

232
def exit_if_run_failed(
5 ✔
233
    cmd: str,
234
    env: dict[str, str] | None = None,
235
    _exit: bool = False,
236
    dry: bool = False,
237
    **kw: Any,
238
) -> subprocess.CompletedProcess[str]:
239
    return Shell(cmd, **kw).finish(env=env, _exit=_exit, dry=dry)
5 ✔
240

241

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

245

246
def read_version_from_file(
5 ✔
247
    package_name: str, work_dir: Path | None = None, toml_text: str | None = None
248
) -> str:
249
    if not package_name and toml_text:
5 ✔
250
        pattern = re.compile(r"version\s*=")
5 ✔
251
        for line in toml_text.splitlines():
5 ✔
252
            if pattern.match(line):
5 ✔
253
                return _parse_version(line, pattern)
5 ✔
254
    version_file = BumpUp.parse_filename(toml_text, work_dir, package_name)
5 ✔
255
    if version_file == TOML_FILE:
5 ✔
256
        if toml_text is None:
5 ✔
257
            toml_text = Project.load_toml_text()
5 ✔
258
        context = tomllib.loads(toml_text)
5 ✔
259
        with contextlib.suppress(KeyError):
5 ✔
260
            return cast(str, context["project"]["version"])
5 ✔
261
        with contextlib.suppress(KeyError):  # Poetry V1
5 ✔
262
            return cast(str, context["tool"]["poetry"]["version"])
5 ✔
263
        secho(f"WARNING: can not find 'version' item in {version_file}!")
5 ✔
264
        return "0.0.0"
5 ✔
265
    pattern = re.compile(r"__version__\s*=")
5 ✔
266
    all_lines = Path(version_file).read_text("utf-8").strip().splitlines()
5 ✔
267
    for line in all_lines:
5 ✔
268
        if pattern.match(line):
5 ✔
269
            return _parse_version(line, pattern)
5 ✔
270
    pattern = re.compile(r"VERSION\s*=")
×
271
    for line in all_lines:
×
272
        if pattern.match(line):
×
273
            return _parse_version(line, pattern)
×
274
    secho(f"WARNING: can not find version pattern in {version_file}!")
×
275
    return "0.0.0"
×
276

277

278
def _get_frontend_version() -> tuple[Path, str] | None:
5 ✔
279
    try:
×
280
        frontend_version_file = Project.get_work_dir("package.json", be_file=True)
×
281
    except EnvError:
×
282
        return None
×
283
    try:
×
284
        from asynctor.jsons import json_loads
×
285
    except ImportError:
×
286
        from json import loads as json_loads  # type:ignore[assignment]
×
287
    content = frontend_version_file.read_bytes()
×
288
    metadata: dict[str, str] = json_loads(content)  # type:ignore
×
289
    try:
×
290
        current_version = metadata["version"]
×
291
    except (KeyError, TypeError):
×
292
        return None
×
293
    with contextlib.suppress(ValueError):
×
294
        frontend_version_file = frontend_version_file.relative_to(Path.cwd())
×
295
    return frontend_version_file, current_version
×
296

297

298
@overload
299
def get_current_version(
300
    verbose: bool = False,
301
    is_poetry: bool | None = None,
302
    package_name: str | None = None,
303
    *,
304
    check_version: Literal[False] = False,
305
) -> str: ...
306

307

308
@overload
309
def get_current_version(
310
    verbose: bool = False,
311
    is_poetry: bool | None = None,
312
    package_name: str | None = None,
313
    *,
314
    check_version: Literal[True],
315
) -> tuple[bool, str]: ...
316

317

318
def get_current_version(
5 ✔
319
    verbose: bool = False,
320
    is_poetry: bool | None = None,
321
    package_name: str | None = None,
322
    *,
323
    check_version: bool = False,
324
) -> str | tuple[bool, str]:
325
    if is_poetry is True or Project.manage_by_poetry():
5 ✔
326
        out = _get_poetry_project_version(verbose)
5 ✔
327
        if check_version:
5 ✔
328
            return True, out
5 ✔
329
        return out
5 ✔
330
    toml_text = work_dir = None
5 ✔
331
    if package_name is None:
5 ✔
332
        try:
5 ✔
333
            work_dir = Project.get_work_dir()
5 ✔
334
        except EnvError:
×
335
            if (res := _get_frontend_version()) is None:
×
336
                raise
×
337
            current_version = res[1]
×
338
            if check_version:
×
339
                return False, current_version
×
340
            return current_version
×
341
        else:
342
            toml_text = Project.load_toml_text()
5 ✔
343
            doc = tomllib.loads(toml_text)
5 ✔
344
            project_name = doc.get("project", {}).get("name", work_dir.name)
5 ✔
345
            package_name = re.sub(r"[- ]", "_", project_name)
5 ✔
346
    local_version = read_version_from_file(package_name, work_dir, toml_text)
5 ✔
347
    try:
5 ✔
348
        installed_version = importlib_metadata.version(package_name)
5 ✔
349
    except importlib_metadata.PackageNotFoundError:
5 ✔
350
        installed_version = ""
5 ✔
351
    current_version = local_version or installed_version
5 ✔
352
    if not current_version:
5 ✔
353
        raise FastDevCliError(f"Failed to get current version of {package_name!r}")
×
354
    if check_version:
5 ✔
355
        is_conflict = bool(local_version) and local_version != installed_version
5 ✔
356
        return is_conflict, current_version
5 ✔
357
    return current_version
5 ✔
358

359

360
def _get_poetry_project_version(verbose: bool) -> str:
5 ✔
361
    cmd = ["poetry", "version", "-s"]
5 ✔
362
    if verbose:
5 ✔
363
        echo(f"--> {' '.join(cmd)}")
5 ✔
364
    if out := capture_cmd_output(cmd, raises=True):
5 ✔
365
        out = out.splitlines()[-1].strip().split()[-1]
5 ✔
366
    return out
5 ✔
367

368

369
def _ensure_bool(value: bool | OptionInfo) -> bool:
5 ✔
370
    if isinstance(value, bool):
5 ✔
371
        return value
5 ✔
372
    return bool(getattr(value, "default", False))
5 ✔
373

374

375
def _ensure_str(value: str | OptionInfo | None) -> str | None:
5 ✔
376
    if isinstance(value, str) or value is None:
5 ✔
377
        return value
5 ✔
378
    return getattr(value, "default", "")
5 ✔
379

380

381
class DryRun:
5 ✔
382
    def __init__(self, _exit: bool = False, dry: bool = False) -> None:
5 ✔
383
        self.dry = _ensure_bool(dry)
5 ✔
384
        self._exit = _exit
5 ✔
385

386
    def gen(self) -> str:
5 ✔
387
        raise NotImplementedError
5 ✔
388

389
    def run(self) -> None:
5 ✔
390
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
5 ✔
391

392

393
class BumpUp(DryRun):
5 ✔
394
    class PartChoices(StrEnum):
5 ✔
395
        patch = "patch"
5 ✔
396
        minor = "minor"
5 ✔
397
        major = "major"
5 ✔
398

399
    def __init__(
5 ✔
400
        self,
401
        commit: bool,
402
        part: str,
403
        filename: str | None = None,
404
        dry: bool = False,
405
        no_sync: bool = False,
406
        emoji: bool | None = None,
407
    ) -> None:
408
        self.commit = commit
5 ✔
409
        self.part = part
5 ✔
410
        if filename is None:
5 ✔
411
            try:
5 ✔
412
                filename = self.parse_filename()
5 ✔
413
            except EnvError:
5 ✔
414
                if (res := _get_frontend_version()) is not None:
×
415
                    filename = res[0].name
×
416
                else:
417
                    raise
×
418
        self.filename = filename
5 ✔
419
        self._no_sync = no_sync
5 ✔
420
        self._emoji = emoji
5 ✔
421
        super().__init__(dry=dry)
5 ✔
422

423
    @staticmethod
5 ✔
424
    def get_last_commit_message(raises: bool = False) -> str:
5 ✔
425
        cmd = 'git show --pretty=format:"%s" -s HEAD'
5 ✔
426
        return capture_cmd_output(cmd, raises=raises)
5 ✔
427

428
    @classmethod
5 ✔
429
    def should_add_emoji(cls) -> bool:
5 ✔
430
        """
431
        If last commit message is startswith emoji,
432
        add a ⬆️ flag at the prefix of bump up commit message.
433
        """
434
        try:
5 ✔
435
            first_char = cls.get_last_commit_message(raises=True)[0]
5 ✔
436
        except (IndexError, ShellCommandError):
5 ✔
437
            return False
5 ✔
438
        else:
439
            return is_emoji(first_char)
5 ✔
440

441
    @staticmethod
5 ✔
442
    def parse_dynamic_version(
5 ✔
443
        toml_text: str | None,
444
        context: dict[str, Any],
445
        work_dir: Path | None = None,
446
    ) -> str | None:
447
        if work_dir is None:
5 ✔
448
            work_dir = Project.get_work_dir()
5 ✔
449
        for tool in ("pdm", "hatch"):
5 ✔
450
            with contextlib.suppress(KeyError):
5 ✔
451
                version_path = cast(str, context["tool"][tool]["version"]["path"])
5 ✔
452
                if (
5 ✔
453
                    Path(version_path).exists()
454
                    or work_dir.joinpath(version_path).exists()
455
                ):
456
                    return version_path
5 ✔
457
        # e.g.: version = { source = "file", path = "fast_dev_cli/__init__.py" }
458
        v_key = "version = "
5 ✔
459
        p_key = 'path = "'
5 ✔
460
        if toml_text is None:
5 ✔
461
            toml_text = Project.load_toml_text()
×
462
        for line in toml_text.splitlines():
5 ✔
463
            if not line.startswith(v_key):
×
464
                continue
×
465
            if p_key in (value := line.split(v_key, 1)[-1].split("#")[0]):
×
466
                filename = value.split(p_key, 1)[-1].split('"')[0]
×
467
                if work_dir.joinpath(filename).exists():
×
468
                    return filename
×
469
        return None
5 ✔
470

471
    @classmethod
5 ✔
472
    def parse_filename(
5 ✔
473
        cls,
474
        toml_text: str | None = None,
475
        work_dir: Path | None = None,
476
        package_name: str | None = None,
477
        context: dict[str, Any] | None = None,
478
    ) -> str:
479
        if context is None:
5 ✔
480
            if toml_text is None:
5 ✔
481
                toml_text = Project.load_toml_text()
5 ✔
482
            context = tomllib.loads(toml_text)
5 ✔
483
        is_dynamic_version = False
5 ✔
484
        with contextlib.suppress(KeyError):
5 ✔
485
            if "version" in context["project"]["dynamic"]:
5 ✔
486
                is_dynamic_version = True
5 ✔
487
        if is_dynamic_version:
5 ✔
488
            if filename := cls.parse_dynamic_version(toml_text, context, work_dir):
5 ✔
489
                return filename
5 ✔
490
            yellow_warn("Failed to find version file for this dynamic version project.")
×
491
        by_version_plugin = False
5 ✔
492
        try:
5 ✔
493
            ver = context["project"]["version"]
5 ✔
494
        except KeyError:
5 ✔
495
            pass
5 ✔
496
        else:
497
            if isinstance(ver, str):
5 ✔
498
                if ver in ("0", "0.0.0"):
5 ✔
499
                    by_version_plugin = True
5 ✔
500
                elif re.match(r"\d+\.\d+\.\d+", ver):
5 ✔
501
                    return TOML_FILE
5 ✔
502
        if not by_version_plugin:
5 ✔
503
            try:
5 ✔
504
                version_value = context["tool"]["poetry"]["version"]
5 ✔
505
            except KeyError:
5 ✔
506
                if not Project.manage_by_poetry() and (
5 ✔
507
                    filename := cls.parse_dynamic_version(toml_text, context, work_dir)
508
                ):
509
                    return filename
×
510
            else:
511
                by_version_plugin = version_value in ("0", "0.0.0", "init")
5 ✔
512
        if by_version_plugin:
5 ✔
513
            return cls.parse_plugin_version(context, package_name)
5 ✔
514
        return TOML_FILE
5 ✔
515

516
    @staticmethod
5 ✔
517
    def parse_plugin_version(context: dict[str, Any], package_name: str | None) -> str:
5 ✔
518
        try:
5 ✔
519
            package_item = context["tool"]["poetry"]["packages"]
5 ✔
520
        except KeyError:
5 ✔
521
            try:
5 ✔
522
                project_name = context["project"]["name"]
5 ✔
523
            except KeyError:
5 ✔
524
                packages: list[tuple[str, str]] = []
5 ✔
525
            else:
526
                packages = [(poetry_module_name(project_name), "")]
×
527
        else:
528
            packages = [
5 ✔
529
                (j, i.get("from", "")) for i in package_item if (j := i.get("include"))
530
            ]
531
        # In case of managed by `poetry-plugin-version`
532
        cwd = Path.cwd()
5 ✔
533
        pattern = re.compile(r"__version__\s*=\s*['\"]")
5 ✔
534
        ds: list[Path] = []
5 ✔
535
        if package_name is not None:
5 ✔
536
            packages.insert(0, (package_name, ""))
×
537
        for pack_name, source_dir in packages:
5 ✔
538
            ds.append(cwd / pack_name)
5 ✔
539
            ds.append(cwd / "src" / pack_name)
5 ✔
540
            if source_dir and source_dir != "src":
5 ✔
541
                ds.append(cwd / source_dir / pack_name)
5 ✔
542
        module_name = poetry_module_name(cwd.name)
5 ✔
543
        ds.extend([cwd / module_name, cwd / "src" / module_name, cwd])
5 ✔
544
        for d in ds:
5 ✔
545
            init_file = d / "__init__.py"
5 ✔
546
            if (init_file.exists() and pattern.search(init_file.read_text("utf8"))) or (
5 ✔
547
                (init_file := init_file.with_name("__version__.py")).exists()
548
                and pattern.search(init_file.read_text("utf8"))
549
            ):
550
                break
5 ✔
551
        else:
552
            raise ParseError("Version file not found! Where are you now?")
5 ✔
553
        return os.path.relpath(init_file, cwd)
5 ✔
554

555
    def get_part(self, s: str) -> str:
5 ✔
556
        choices: dict[str, str] = {}
5 ✔
557
        for i, p in enumerate(self.PartChoices, 1):
5 ✔
558
            v = str(p)
5 ✔
559
            choices.update({str(i): v, v: v})
5 ✔
560
        try:
5 ✔
561
            return choices[s]
5 ✔
562
        except KeyError as e:
5 ✔
563
            echo(f"Invalid part: {s!r}")
5 ✔
564
            raise Exit(1) from e
5 ✔
565

566
    def gen(self) -> str:
5 ✔
567
        should_sync, _version = get_current_version(check_version=True)
5 ✔
568
        filename = self.filename
5 ✔
569
        echo(f"Current version(@{filename}): {_version}")
5 ✔
570
        if self.part:
5 ✔
571
            part = self.get_part(self.part)
5 ✔
572
        else:
573
            part = "patch"
5 ✔
574
            if a := input("Which one?").strip():
5 ✔
575
                part = self.get_part(a)
5 ✔
576
        self.part = part
5 ✔
577
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5 ✔
578
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
5 ✔
579
        if self.commit:
5 ✔
580
            if part != "patch":
5 ✔
581
                cmd += " --tag"
5 ✔
582
            cmd += " --commit"
5 ✔
583
            if self._emoji or (self._emoji is None and self.should_add_emoji()):
5 ✔
584
                cmd += " --message-emoji=1"
5 ✔
585
            if not load_bool("DONT_GIT_PUSH"):
5 ✔
586
                cmd += " && git push && git push --tags && git log -1"
5 ✔
587
        else:
588
            cmd += " --allow-dirty"
5 ✔
589
        if (
5 ✔
590
            should_sync
591
            and not self._no_sync
592
            and (sync := Project.get_sync_command(only_me=True))
593
        ):
594
            cmd = f"{sync} && " + cmd
5 ✔
595
        return cmd
5 ✔
596

597
    def run(self) -> None:
5 ✔
598
        super().run()
5 ✔
599
        if not self.commit and not self.dry:
5 ✔
600
            new_version = get_current_version(True)
5 ✔
601
            echo(new_version)
5 ✔
602
            if self.part != "patch":
5 ✔
603
                echo("You may want to pin tag by `fast tag`")
5 ✔
604

605

606
def _echo_version(version_file: Any, value: str) -> None:
5 ✔
607
    styled = typer.style(value, bold=True)
5 ✔
608
    echo(f"Version value in {version_file}: " + styled)
5 ✔
609

610

611
@cli.command()
5 ✔
612
def version() -> None:
5 ✔
613
    """Show the version of this tool"""
614
    echo("Fast Dev Cli Version: " + typer.style(__version__, fg=typer.colors.BLUE))
5 ✔
615
    with contextlib.suppress(FileNotFoundError, KeyError):
5 ✔
616
        try:
5 ✔
617
            toml_text = Project.load_toml_text()
5 ✔
618
        except EnvError:
×
619
            if (res := _get_frontend_version()) is not None:
×
620
                _echo_version(*res)
×
621
                return
×
622
            raise
×
623
        doc = tomllib.loads(toml_text)
5 ✔
624
        if value := doc.get("project", {}).get("version", ""):
5 ✔
625
            styled = typer.style(value, bold=True, fg=typer.colors.CYAN)
×
626
            if project_name := doc["project"].get("name", ""):
×
627
                echo(f"{project_name} version: " + styled)
×
628
            else:
629
                echo(f"Got Version from {TOML_FILE}: " + styled)
×
630
            return
×
631
        version_file = doc["tool"]["pdm"]["version"]["path"]
5 ✔
632
        text = Project.get_work_dir().joinpath(version_file).read_text(encoding="utf-8")
5 ✔
633
        varname = "__version__"
5 ✔
634
        for line in text.splitlines():
5 ✔
635
            if line.strip().startswith(varname):
5 ✔
636
                value = line.split("=", 1)[-1].strip().strip('"').strip("'")
5 ✔
637
                _echo_version(version_file, value)
5 ✔
638
                break
5 ✔
639

640

641
@cli.command(name="bump")
5 ✔
642
def bump_version(
5 ✔
643
    part: BumpUp.PartChoices,
644
    commit: bool = Option(
645
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
646
    ),
647
    emoji: bool | None = Option(
648
        None, "--emoji", help="Whether add emoji prefix to commit message"
649
    ),
650
    no_sync: bool = Option(
651
        False, "--no-sync", help="Do not run sync command to update version"
652
    ),
653
    dry: bool = DryOption,
654
) -> None:
655
    """Bump up version string in pyproject.toml"""
656
    if emoji is not None:
5 ✔
657
        emoji = _ensure_bool(emoji)
5 ✔
658
    return BumpUp(
5 ✔
659
        _ensure_bool(commit),
660
        getattr(part, "value", part),
661
        no_sync=_ensure_bool(no_sync),
662
        emoji=emoji,
663
        dry=dry,
664
    ).run()
665

666

667
def bump() -> None:
5 ✔
668
    part, commit = "", False
5 ✔
669
    if args := sys.argv[2:]:
5 ✔
670
        if "-c" in args or "--commit" in args:
5 ✔
671
            commit = True
5 ✔
672
        for a in args:
5 ✔
673
            if not a.startswith("-"):
5 ✔
674
                part = a
5 ✔
675
                break
5 ✔
676
    return BumpUp(commit, part, no_sync="--no-sync" in args, dry="--dry" in args).run()
5 ✔
677

678

679
class Project:
5 ✔
680
    path_depth = 5
5 ✔
681
    _tool: ToolName | None = None
5 ✔
682

683
    @staticmethod
5 ✔
684
    def is_poetry_v2(text: str) -> bool:
5 ✔
685
        return 'build-backend = "poetry' in text
5 ✔
686

687
    @staticmethod
5 ✔
688
    def get_poetry_version(command: str = "poetry") -> str:
5 ✔
689
        pattern = r"(\d+\.\d+\.\d+)"
5 ✔
690
        text = capture_cmd_output(f"{command} --version")
5 ✔
691
        for expr in (
5 ✔
692
            rf"Poetry \(version {pattern}\)",
693
            rf"Poetry.*version.*{pattern}.*\)",
694
            rf"{pattern}",
695
        ):
696
            if m := re.search(expr, text):
5 ✔
697
                return m.group(1)
5 ✔
698
        return ""
×
699

700
    @staticmethod
5 ✔
701
    def work_dir(
5 ✔
702
        name: str, parent: Path, depth: int, be_file: bool = False
703
    ) -> Path | None:
704
        for _ in range(depth):
5 ✔
705
            if (f := parent.joinpath(name)).exists():
5 ✔
706
                if be_file:
5 ✔
707
                    return f
5 ✔
708
                return parent
5 ✔
709
            parent = parent.parent
5 ✔
710
        return None
5 ✔
711

712
    @classmethod
5 ✔
713
    def get_work_dir(
5 ✔
714
        cls: type[Self],
715
        name: str = TOML_FILE,
716
        cwd: Path | None = None,
717
        allow_cwd: bool = False,
718
        be_file: bool = False,
719
    ) -> Path:
720
        cwd = cwd or Path.cwd()
5 ✔
721
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5 ✔
722
            return d
5 ✔
723
        if allow_cwd:
5 ✔
724
            return cls.get_root_dir(cwd)
5 ✔
725
        raise EnvError(f"{name} not found! Make sure this is a python project.")
5 ✔
726

727
    @classmethod
5 ✔
728
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
5 ✔
729
        toml_file = cls.get_work_dir(name, be_file=True)
5 ✔
730
        return toml_file.read_text("utf8")
5 ✔
731

732
    @classmethod
5 ✔
733
    def manage_by_poetry(cls: type[Self], cache: bool = False) -> bool:
5 ✔
734
        return cls.get_manage_tool(cache=cache) == "poetry"
5 ✔
735

736
    @classmethod
5 ✔
737
    def get_manage_tool(cls: type[Self], cache: bool = False) -> ToolName | None:
5 ✔
738
        if cache and cls._tool:
5 ✔
739
            return cls._tool
5 ✔
740
        try:
5 ✔
741
            text = cls.load_toml_text()
5 ✔
742
        except EnvError:
5 ✔
743
            return None
5 ✔
744
        backend = ""
5 ✔
745
        skip_uv = load_bool("FASTDEVCLI_SKIP_UV")
5 ✔
746
        with contextlib.suppress(KeyError, tomllib.TOMLDecodeError):
5 ✔
747
            doc = tomllib.loads(text)
5 ✔
748
            backend = doc["build-system"]["build-backend"]
5 ✔
749
            if skip_uv:
5 ✔
750
                for t in ("pdm", "poetry"):
×
751
                    if t in backend:
×
752
                        cls._tool = t
×
753
                        return cls._tool
×
754
        return cls._get_manage_tool(text, backend, skip_uv)
5 ✔
755

756
    @classmethod
5 ✔
757
    def _get_manage_tool(
5 ✔
758
        cls: type[Self], text: str, backend: str, skip_uv: bool
759
    ) -> ToolName | None:
760
        work_dir: Path | None = None
5 ✔
761
        uv_lock_exists: bool | None = None
5 ✔
762
        if skip_uv:
5 ✔
763
            for name in ("pdm", "poetry"):
×
764
                if f"[tool.{name}]" in text:
×
765
                    cls._tool = name
×
766
                    return cls._tool
×
767
            work_dir = cls.get_work_dir(allow_cwd=True)
×
768
            for name in ("pdm", "poetry"):
×
769
                if Path(work_dir, f"{name}.lock").exists():
×
770
                    cls._tool = cast(ToolName, name)
×
771
                    return cls._tool
×
772
            if uv_lock_exists := Path(work_dir, "uv.lock").exists():
×
773
                # Use pdm when uv is not available for uv managed project
774
                cls._tool = "pdm"
×
775
                return cls._tool
×
776
            return None
×
777
        if work_dir is None:
5 ✔
778
            work_dir = cls.get_work_dir(allow_cwd=True)
5 ✔
779
        if uv_lock_exists is None:
5 ✔
780
            uv_lock_exists = Path(work_dir, "uv.lock").exists()
5 ✔
781
        if uv_lock_exists:
5 ✔
782
            cls._tool = "uv"
5 ✔
783
            return cls._tool
5 ✔
784
        return cls._parse_manage_tool(work_dir, text, backend)
5 ✔
785

786
    @classmethod
5 ✔
787
    def _parse_manage_tool(
5 ✔
788
        cls: type[Self], work_dir: Path, text: str, backend: str
789
    ) -> ToolName | None:
790
        pdm_lock_exists = Path(work_dir, "pdm.lock").exists()
5 ✔
791
        poetry_lock_exists = Path(work_dir, "poetry.lock").exists()
5 ✔
792
        match pdm_lock_exists + poetry_lock_exists:
5 ✔
793
            case 1:
5 ✔
794
                cls._tool = "pdm" if pdm_lock_exists else "poetry"
×
795
                return cls._tool
×
796
            case _ as x:
5 ✔
797
                if backend:
5 ✔
798
                    for t in ("pdm", "poetry"):
5 ✔
799
                        if t in backend:
5 ✔
800
                            cls._tool = t
5 ✔
801
                            return cls._tool
5 ✔
802
                for name in ("pdm", "poetry"):
5 ✔
803
                    if f"[tool.{name}]" in text:
5 ✔
804
                        cls._tool = name
5 ✔
805
                        return cls._tool
5 ✔
806
                if x == 2:
5 ✔
807
                    cls._tool = (
×
808
                        "poetry" if load_bool("FASTDEVCLI_PREFER_POETRY") else "pdm"
809
                    )
810
                    return cls._tool
1 ✔
811
        if "[tool.uv]" in text or load_bool("FASTDEVCLI_PREFER_uv"):
5 ✔
812
            cls._tool = "uv"
5 ✔
813
            return cls._tool
5 ✔
814
        # Poetry 2.0 default to not include the '[tool.poetry]' section
815
        if cls.is_poetry_v2(text):
5 ✔
816
            cls._tool = "poetry"
×
817
            return cls._tool
×
818
        return None
5 ✔
819

820
    @staticmethod
5 ✔
821
    def python_exec_dir() -> Path:
5 ✔
822
        return Path(sys.executable).parent
5 ✔
823

824
    @classmethod
5 ✔
825
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5 ✔
826
        root = cwd or Path.cwd()
5 ✔
827
        venv_parent = cls.python_exec_dir().parent.parent
5 ✔
828
        if root.is_relative_to(venv_parent):
5 ✔
829
            root = venv_parent
5 ✔
830
        return root
5 ✔
831

832
    @classmethod
5 ✔
833
    def is_pdm_project(cls, strict: bool = True, cache: bool = False) -> bool:
5 ✔
834
        if cls.get_manage_tool(cache=cache) != "pdm":
5 ✔
835
            return False
5 ✔
836
        if strict:
×
837
            lock_file = cls.get_work_dir() / "pdm.lock"
×
838
            return lock_file.exists()
×
839
        return True
×
840

841
    @classmethod
5 ✔
842
    def get_sync_command(
5 ✔
843
        cls, prod: bool = True, doc: dict[str, Any] | None = None, only_me: bool = False
844
    ) -> str:
845
        pdm_i = "pdm install --frozen" + " --prod" * prod
5 ✔
846
        if cls.is_pdm_project():
5 ✔
847
            return pdm_i
×
848
        elif cls.manage_by_poetry(cache=True):
5 ✔
849
            cmd = "poetry install"
5 ✔
850
            if prod:
5 ✔
851
                if doc is None:
5 ✔
852
                    doc = tomllib.loads(cls.load_toml_text())
5 ✔
853
                if doc.get("project", {}).get("dependencies") or any(
5 ✔
854
                    i != "python"
855
                    for i in doc.get("tool", {})
856
                    .get("poetry", {})
857
                    .get("dependencies", [])
858
                ):
859
                    cmd += " --only=main"
×
860
            return cmd
5 ✔
861
        elif cls.get_manage_tool(cache=True) == "uv":
×
862
            install_me = "uv pip install -e ."
×
863
            if doc is None:
×
864
                doc = tomllib.loads(cls.load_toml_text())
×
865
            is_distribution = (
×
866
                doc.get("tool", {}).get("pdm", {}).get("distribution") is not False
867
            )
868
            if only_me:
×
869
                return install_me if is_distribution else pdm_i
×
870
            cmd = "uv sync --inexact" + " --no-dev" * prod
×
871
            if is_distribution:
×
872
                cmd += f" && {install_me}"
×
873
        return ""
×
874

875
    @classmethod
5 ✔
876
    def sync_dependencies(cls, prod: bool = True) -> None:
5 ✔
877
        if cmd := cls.get_sync_command():
×
878
            run_and_echo(cmd)
×
879

880

881
class UpgradeDependencies(Project, DryRun):
5 ✔
882
    def __init__(
5 ✔
883
        self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
884
    ) -> None:
885
        super().__init__(_exit, dry)
5 ✔
886
        self._tool = tool
5 ✔
887

888
    class DevFlag(StrEnum):
5 ✔
889
        new = "[tool.poetry.group.dev.dependencies]"
5 ✔
890
        old = "[tool.poetry.dev-dependencies]"
5 ✔
891

892
    @staticmethod
5 ✔
893
    def parse_value(version_info: str, key: str) -> str:
5 ✔
894
        """Pick out the value for key in version info.
895

896
        Example::
897
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
898
            >>> UpgradeDependencies.parse_value(s, 'extras')
899
            'all'
900
            >>> UpgradeDependencies.parse_value(s, 'optional')
901
            'true'
902
            >>> UpgradeDependencies.parse_value(s, 'version')
903
            '^0.9.0'
904
        """
905
        sep = key + " = "
5 ✔
906
        rest = version_info.split(sep, 1)[-1].strip(" =")
5 ✔
907
        if rest.startswith("["):
5 ✔
908
            rest = rest[1:].split("]")[0]
5 ✔
909
        elif rest.startswith('"'):
5 ✔
910
            rest = rest[1:].split('"')[0]
5 ✔
911
        else:
912
            rest = rest.split(",")[0].split("}")[0]
5 ✔
913
        return rest.strip().replace('"', "")
5 ✔
914

915
    @staticmethod
5 ✔
916
    def no_need_upgrade(version_info: str, line: str) -> bool:
5 ✔
917
        if (v := version_info.replace(" ", "")).startswith("{url="):
5 ✔
918
            echo(f"No need to upgrade for: {line}")
5 ✔
919
            return True
5 ✔
920
        if (f := "version=") in v:
5 ✔
921
            v = v.split(f)[1].strip('"').split('"')[0]
5 ✔
922
        if v == "*":
5 ✔
923
            echo(f"Skip wildcard line: {line}")
5 ✔
924
            return True
5 ✔
925
        elif v == "[":
5 ✔
926
            echo(f"Skip complex dependence: {line}")
5 ✔
927
            return True
5 ✔
928
        elif v.startswith((">", "<")) or v[0].isdigit():
5 ✔
929
            echo(f"Ignore bigger/smaller/equal: {line}")
5 ✔
930
            return True
5 ✔
931
        return False
5 ✔
932

933
    @classmethod
5 ✔
934
    def build_args(
5 ✔
935
        cls: type[Self], package_lines: list[str]
936
    ) -> tuple[list[str], dict[str, list[str]]]:
937
        args: list[str] = []  # ['typer[all]', 'fastapi']
5 ✔
938
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
5 ✔
939
        for no, line in enumerate(package_lines, 1):
5 ✔
940
            if (
5 ✔
941
                not (m := line.strip())
942
                or m.startswith("#")
943
                or m == "]"
944
                or (m.startswith("{") and m.strip(",").endswith("}"))
945
            ):
946
                continue
5 ✔
947
            try:
5 ✔
948
                package, version_info = m.split("=", 1)
5 ✔
949
            except ValueError as e:
5 ✔
950
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
5 ✔
951
            if (package := package.strip()).lower() == "python":
5 ✔
952
                continue
5 ✔
953
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
5 ✔
954
                continue
5 ✔
955
            if (extras_tip := "extras") in version_info:
5 ✔
956
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
5 ✔
957
            item = f'"{package}@latest"'
5 ✔
958
            key = None
5 ✔
959
            if (pf := "platform") in version_info:
5 ✔
960
                platform = cls.parse_value(version_info, pf)
5 ✔
961
                key = f"--{pf}={platform}"
5 ✔
962
            if (sc := "source") in version_info:
5 ✔
963
                source = cls.parse_value(version_info, sc)
5 ✔
964
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
5 ✔
965
            if "optional = true" in version_info:
5 ✔
966
                key = ("" if key is None else (key + " ")) + "--optional"
5 ✔
967
            if key is not None:
5 ✔
968
                specials[key] = specials.get(key, []) + [item]
5 ✔
969
            else:
970
                args.append(item)
5 ✔
971
        return args, specials
5 ✔
972

973
    @classmethod
5 ✔
974
    def should_with_dev(cls: type[Self]) -> bool:
5 ✔
975
        text = cls.load_toml_text()
5 ✔
976
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5 ✔
977

978
    @staticmethod
5 ✔
979
    def parse_item(toml_str: str) -> list[str]:
5 ✔
980
        lines: list[str] = []
5 ✔
981
        for line in toml_str.splitlines():
5 ✔
982
            if (line := line.strip()).startswith("["):
5 ✔
983
                if lines:
5 ✔
984
                    break
5 ✔
985
            elif line:
5 ✔
986
                lines.append(line)
5 ✔
987
        return lines
5 ✔
988

989
    @classmethod
5 ✔
990
    def get_args(
5 ✔
991
        cls: type[Self], toml_text: str | None = None
992
    ) -> tuple[list[str], list[str], list[list[str]], str]:
993
        if toml_text is None:
5 ✔
994
            toml_text = cls.load_toml_text()
5 ✔
995
        main_title = "[tool.poetry.dependencies]"
5 ✔
996
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
5 ✔
997
            toml_text
998
        ):
999
            raise EnvError(
5 ✔
1000
                f"{main_title} not found! Make sure this is a poetry project."
1001
            )
1002
        text = toml_text.split(main_title)[-1]
5 ✔
1003
        dev_flag = "--group dev"
5 ✔
1004
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
5 ✔
1005
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
5 ✔
1006
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
5 ✔
1007
            dev_flag = "--dev"
5 ✔
1008
        others: list[list[str]] = []
5 ✔
1009
        try:
5 ✔
1010
            main_toml, dev_toml = text.split(dev_title)
5 ✔
1011
        except ValueError:
5 ✔
1012
            dev_toml = ""
5 ✔
1013
            main_toml = text
5 ✔
1014
        mains = [] if no_main_deps else cls.parse_item(main_toml)
5 ✔
1015
        devs = cls.parse_item(dev_toml)
5 ✔
1016
        prod_packs, specials = cls.build_args(mains)
5 ✔
1017
        if specials:
5 ✔
1018
            others.extend([[k] + v for k, v in specials.items()])
5 ✔
1019
        dev_packs, specials = cls.build_args(devs)
5 ✔
1020
        if specials:
5 ✔
1021
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
5 ✔
1022
        return prod_packs, dev_packs, others, dev_flag
5 ✔
1023

1024
    @classmethod
5 ✔
1025
    def gen_cmd(cls: type[Self]) -> str:
5 ✔
1026
        main_args, dev_args, others, dev_flags = cls.get_args()
5 ✔
1027
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5 ✔
1028

1029
    @staticmethod
5 ✔
1030
    def to_cmd(
5 ✔
1031
        main_args: list[str],
1032
        dev_args: list[str],
1033
        others: list[list[str]],
1034
        dev_flags: str,
1035
    ) -> str:
1036
        command = "poetry add "
5 ✔
1037
        _upgrade = ""
5 ✔
1038
        if main_args:
5 ✔
1039
            _upgrade = command + " ".join(main_args)
5 ✔
1040
        if dev_args:
5 ✔
1041
            if _upgrade:
5 ✔
1042
                _upgrade += " && "
5 ✔
1043
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5 ✔
1044
        for single in others:
5 ✔
1045
            _upgrade += f" && poetry add {' '.join(single)}"
5 ✔
1046
        return _upgrade
5 ✔
1047

1048
    def gen(self) -> str:
5 ✔
1049
        if self._tool == "uv":
5 ✔
1050
            up = "uv lock --upgrade --verbose"
5 ✔
1051
            deps = "uv sync --inexact --frozen --all-groups --all-extras"
5 ✔
1052
            return f"{up} && {deps}"
5 ✔
1053
        elif self._tool == "pdm":
5 ✔
1054
            return "pdm update --verbose && pdm install -G :all --frozen"
5 ✔
1055
        return self.gen_cmd() + " && poetry lock && poetry update"
5 ✔
1056

1057

1058
@cli.command()
5 ✔
1059
def upgrade(
5 ✔
1060
    tool: str = ToolOption,
1061
    dry: bool = DryOption,
1062
) -> None:
1063
    """Upgrade dependencies in pyproject.toml to latest versions"""
1064
    if not (tool := _ensure_str(tool) or "") or tool == ToolOption.default:
5 ✔
1065
        tool = Project.get_manage_tool() or "uv"
5 ✔
1066
    if tool in get_args(ToolName):
5 ✔
1067
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
5 ✔
1068
    else:
1069
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
5 ✔
1070
        raise typer.Exit(1)
5 ✔
1071

1072

1073
class GitTag(DryRun):
5 ✔
1074
    def __init__(self, message: str, dry: bool, no_sync: bool = False) -> None:
5 ✔
1075
        self.message = message
5 ✔
1076
        self._no_sync = no_sync
5 ✔
1077
        super().__init__(dry=dry)
5 ✔
1078

1079
    @staticmethod
5 ✔
1080
    def has_v_prefix() -> bool:
5 ✔
1081
        return "v" in capture_cmd_output("git tag")
5 ✔
1082

1083
    def should_push(self) -> bool:
5 ✔
1084
        return "git push" in self.git_status
5 ✔
1085

1086
    def gen(self) -> str:
5 ✔
1087
        should_sync, _version = get_current_version(verbose=False, check_version=True)
5 ✔
1088
        if self.has_v_prefix():
5 ✔
1089
            # Add `v` at prefix to compare with bumpversion tool
1090
            _version = "v" + _version
5 ✔
1091
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
5 ✔
1092
        if self.should_push():
5 ✔
1093
            cmd += " && git push"
5 ✔
1094
        if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
5 ✔
1095
            cmd = f"{sync} && " + cmd
×
1096
        return cmd
5 ✔
1097

1098
    @cached_property
5 ✔
1099
    def git_status(self) -> str:
5 ✔
1100
        return capture_cmd_output("git status")
5 ✔
1101

1102
    def mark_tag(self) -> bool:
5 ✔
1103
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5 ✔
1104
            "无文件要提交,干净的工作区" not in self.git_status
1105
        ):
1106
            run_and_echo("git status")
5 ✔
1107
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5 ✔
1108
            return False
5 ✔
1109
        return bool(super().run())
5 ✔
1110

1111
    def run(self) -> None:
5 ✔
1112
        if self.mark_tag() and not self.dry:
5 ✔
1113
            echo("You may want to publish package:\n pdm publish")
5 ✔
1114

1115

1116
@cli.command()
5 ✔
1117
def tag(
5 ✔
1118
    message: str = Option("", "-m", "--message"),
1119
    no_sync: bool = Option(
1120
        False, "--no-sync", help="Do not run sync command to update version"
1121
    ),
1122
    dry: bool = DryOption,
1123
) -> None:
1124
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
1125
    GitTag(message, dry=dry, no_sync=_ensure_bool(no_sync)).run()
5 ✔
1126

1127

1128
class LintCode(DryRun):
5 ✔
1129
    def __init__(
5 ✔
1130
        self,
1131
        args: list[str] | str | None,
1132
        check_only: bool = False,
1133
        _exit: bool = False,
1134
        dry: bool = False,
1135
        bandit: bool = False,
1136
        skip_mypy: bool = False,
1137
        dmypy: bool = False,
1138
        tool: str = ToolOption.default,
1139
        prefix: bool = False,
1140
        up: bool = False,
1141
        sim: bool = True,
1142
        strict: bool = False,
1143
        ty: bool = False,
1144
        fix: bool = True,
1145
    ) -> None:
1146
        self.args = args
5 ✔
1147
        self.check_only = check_only
5 ✔
1148
        self._bandit = bandit
5 ✔
1149
        self._skip_mypy = skip_mypy
5 ✔
1150
        self._use_dmypy = dmypy
5 ✔
1151
        self._tool = tool
5 ✔
1152
        self._prefix = prefix
5 ✔
1153
        self._up = up
5 ✔
1154
        self._sim = sim
5 ✔
1155
        self._strict = strict
5 ✔
1156
        self._ty = _ensure_bool(ty)
5 ✔
1157
        self._fix = _ensure_bool(fix)
5 ✔
1158
        super().__init__(_exit, dry)
5 ✔
1159

1160
    @staticmethod
5 ✔
1161
    def check_lint_tool_installed() -> bool:
5 ✔
1162
        try:
5 ✔
1163
            return check_call("ruff --version")
5 ✔
1164
        except FileNotFoundError:
×
1165
            # Windows may raise FileNotFoundError when ruff not installed
1166
            return False
×
1167

1168
    @staticmethod
5 ✔
1169
    def missing_mypy_exec() -> bool:
5 ✔
1170
        return shutil.which("mypy") is None
5 ✔
1171

1172
    @staticmethod
5 ✔
1173
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
5 ✔
1174
        return (
5 ✔
1175
            paths == "."
1176
            and any(t.startswith("mypy") for t in tools)
1177
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
1178
        )
1179

1180
    @staticmethod
5 ✔
1181
    def get_package_name() -> str:
5 ✔
1182
        root = Project.get_work_dir(allow_cwd=True)
5 ✔
1183
        module_name = root.name.replace("-", "_").replace(" ", "_")
5 ✔
1184
        package_maybe = (module_name, "src")
5 ✔
1185
        for name in package_maybe:
5 ✔
1186
            if root.joinpath(name).is_dir():
5 ✔
1187
                return name
5 ✔
1188
        return "."
5 ✔
1189

1190
    @classmethod
5 ✔
1191
    def to_cmd(
5 ✔
1192
        cls: type[Self],
1193
        paths: str = ".",
1194
        check_only: bool = False,
1195
        bandit: bool = False,
1196
        skip_mypy: bool = False,
1197
        use_dmypy: bool = False,
1198
        tool: str = ToolOption.default,
1199
        with_prefix: bool = False,
1200
        ruff_check_up: bool = False,
1201
        ruff_check_sim: bool = True,
1202
        mypy_strict: bool = False,
1203
        prefer_ty: bool = False,
1204
        ruff_check_fix: bool = True,
1205
    ) -> str:
1206
        if paths != "." and all(i.endswith(".html") for i in paths.split()):
5 ✔
1207
            return f"prettier -w {paths}"
5 ✔
1208
        ruff_rules = ["I", "B"]
5 ✔
1209
        if ruff_check_sim and not load_bool("FASTDEVCLI_NO_SIM"):
5 ✔
1210
            ruff_rules.append("SIM")
5 ✔
1211
        if ruff_check_up or load_bool("FASTDEVCLI_UP"):
5 ✔
1212
            ruff_rules.append("UP")
×
1213
        ruff_check = "ruff check --extend-select=" + ",".join(ruff_rules)
5 ✔
1214
        if (
5 ✔
1215
            ruff_check_fix
1216
            and not check_only
1217
            and (not load_bool("NO_FIX") and not load_bool("FASTDEVCLI_NO_FIX"))
1218
        ):
1219
            ruff_check += " --fix"
5 ✔
1220
        tools = ["ruff format", ruff_check, "mypy"]
5 ✔
1221
        if check_only:
5 ✔
1222
            tools[0] += " --check"
5 ✔
1223
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
5 ✔
1224
            # Sometimes mypy is too slow
1225
            tools = tools[:-1]
5 ✔
1226
        else:
1227
            if prefer_ty or load_bool("FASTDEVCLI_TY"):
5 ✔
1228
                tools[-1] = "ty check"
×
1229
            else:
1230
                if load_bool("IGNORE_MISSING_IMPORTS"):
5 ✔
1231
                    tools[-1] += " --ignore-missing-imports"
5 ✔
1232
                if mypy_strict or load_bool("FASTDEVCLI_STRICT"):
5 ✔
1233
                    tools[-1] += " --strict"
×
1234
        ruff_exists = cls.check_lint_tool_installed()
5 ✔
1235
        prefix = ""
5 ✔
1236
        should_run_by_tool = with_prefix
5 ✔
1237
        requires_mypy = any(tool.startswith("mypy") for tool in tools)
5 ✔
1238
        global_mypy = False
5 ✔
1239
        if requires_mypy:
5 ✔
1240
            # TODO: move this long logic to a single function
1241
            local_bin = Path.home().joinpath(".local/bin")
5 ✔
1242
            if local_bin.joinpath("mypy").exists():
5 ✔
1243
                global_mypy = True
×
1244
                mypy_opt = "--python-executable=.venv/bin/python"
×
1245
                for i, t in enumerate(tools):
×
1246
                    if t.startswith("mypy"):
×
1247
                        if mypy_opt not in t:
×
1248
                            tools[i] = t + " " + mypy_opt
×
1249
                        break
×
1250
            if not should_run_by_tool:
5 ✔
1251
                if is_venv() and Path(sys.argv[0]).parent != local_bin:
5 ✔
1252
                    # Virtual environment activated and fast-dev-cli is installed in it
1253
                    if not ruff_exists:
5 ✔
1254
                        should_run_by_tool = True
5 ✔
1255
                        command = "pipx install ruff"
5 ✔
1256
                        if shutil.which("pipx") is None:
5 ✔
1257
                            ensure_pipx = (
×
1258
                                "pip install --user pipx\n  pipx ensurepath\n  "
1259
                            )
1260
                            command = ensure_pipx + command
×
1261
                        elif prefer_uv_tool():
5 ✔
1262
                            command = "uv tool install ruff"
5 ✔
1263
                        yellow_warn(
5 ✔
1264
                            "You may need to run the following command"
1265
                            f" to install ruff:\n\n  {command}\n"
1266
                        )
1267
                    elif global_mypy:
5 ✔
1268
                        should_run_by_tool = True
×
1269
                    elif cls.missing_mypy_exec():
5 ✔
1270
                        should_run_by_tool = True
5 ✔
1271
                        if check_call('python -c "import fast_dev_cli"'):
5 ✔
1272
                            command = "python -m pip install -U mypy"
5 ✔
1273
                            yellow_warn(
5 ✔
1274
                                "You may need to run the following command"
1275
                                f" to install lint tools:\n\n  {command}\n"
1276
                            )
1277
                elif tool == ToolOption.default:
×
1278
                    root = Project.get_work_dir(allow_cwd=True)
×
1279
                    if py := shutil.which("python"):
×
1280
                        try:
×
1281
                            Path(py).relative_to(root)
×
1282
                        except ValueError:
×
1283
                            # Virtual environment not activated
1284
                            should_run_by_tool = True
×
1285
                else:
1286
                    should_run_by_tool = True
×
1287
        if should_run_by_tool and tool:
5 ✔
1288
            if tool == ToolOption.default:
5 ✔
1289
                tool = Project.get_manage_tool() or ""
5 ✔
1290
            if tool:
5 ✔
1291
                prefix = tool + " run "
5 ✔
1292
                if tool == "uv":
5 ✔
1293
                    if is_windows():
5 ✔
1294
                        prefix += "--no-sync "
×
1295
                    elif Path(bin_dir := ".venv/bin/").exists():
5 ✔
1296
                        prefix = bin_dir
5 ✔
1297
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
5 ✔
1298
            tools[-1] = "dmypy run"
5 ✔
1299
        cmd = " && ".join(
5 ✔
1300
            (
1301
                tool
1302
                # `ruff <command>` get the same result with `pdm run ruff <command>`.
1303
                # Other tools should run inside the selected environment.
1304
                if (
1305
                    ruff_exists
1306
                    and tool.startswith("ruff")
1307
                    or (global_mypy and tool.startswith("mypy"))
1308
                )
1309
                else prefix + tool
1310
            )
1311
            + f" {paths}"
1312
            for tool in tools
1313
        )
1314
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
5 ✔
1315
            command = prefix + "bandit"
5 ✔
1316
            if Path("pyproject.toml").exists():
5 ✔
1317
                toml_text = Project.load_toml_text()
5 ✔
1318
                if "[tool.bandit" in toml_text:
5 ✔
1319
                    command += " -c pyproject.toml"
5 ✔
1320
            if paths == "." and " -c " not in command:
5 ✔
1321
                paths = cls.get_package_name()
5 ✔
1322
            command += f" -r {paths}"
5 ✔
1323
            cmd += " && " + command
5 ✔
1324
        return cmd
5 ✔
1325

1326
    def gen(self) -> str:
5 ✔
1327
        paths = "."
5 ✔
1328
        if args := self.args:
5 ✔
1329
            ps = args.split() if isinstance(args, str) else [str(i) for i in args]
5 ✔
1330
            if len(ps) == 1:
5 ✔
1331
                paths = ps[0]
5 ✔
1332
                if (
5 ✔
1333
                    paths != "."
1334
                    # `Path("a.").suffix` got "." in py3.14 and got "" with py<3.14
1335
                    and (p := Path(paths)).suffix in ("", ".")
1336
                    and not p.exists()
1337
                ):
1338
                    # e.g.:
1339
                    # stem -> stem.py
1340
                    # me. -> me.py
1341
                    if paths.endswith("."):
×
1342
                        p = p.with_name(paths[:-1])
×
1343
                    for suffix in (".py", ".html"):
×
1344
                        p = p.with_suffix(suffix)
×
1345
                        if p.exists():
×
1346
                            paths = p.name
×
1347
                            break
×
1348
            else:
1349
                paths = " ".join(ps)
×
1350
        return self.to_cmd(
5 ✔
1351
            paths,
1352
            self.check_only,
1353
            self._bandit,
1354
            self._skip_mypy,
1355
            self._use_dmypy,
1356
            tool=self._tool,
1357
            with_prefix=self._prefix,
1358
            ruff_check_up=self._up,
1359
            ruff_check_sim=self._sim,
1360
            mypy_strict=self._strict,
1361
            prefer_ty=self._ty,
1362
            ruff_check_fix=self._fix,
1363
        )
1364

1365

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

1369

1370
def lint(
5 ✔
1371
    files: list[str] | str | None = None,
1372
    dry: bool = False,
1373
    bandit: bool = False,
1374
    skip_mypy: bool = False,
1375
    dmypy: bool = False,
1376
    tool: str = ToolOption.default,
1377
    prefix: bool = False,
1378
    up: bool = False,
1379
    sim: bool = True,
1380
    strict: bool = False,
1381
    ty: bool = False,
1382
    fix: bool = True,
1383
) -> None:
1384
    if files is None:
5 ✔
1385
        files = parse_files(sys.argv[1:])
5 ✔
1386
    if files and files[0] == "lint":
5 ✔
1387
        files = files[1:]
5 ✔
1388
    LintCode(
5 ✔
1389
        files,
1390
        dry=dry,
1391
        skip_mypy=skip_mypy,
1392
        bandit=bandit,
1393
        dmypy=dmypy,
1394
        tool=tool,
1395
        prefix=prefix,
1396
        up=up,
1397
        sim=sim,
1398
        strict=strict,
1399
        ty=ty,
1400
        fix=fix,
1401
    ).run()
1402

1403

1404
def check(
5 ✔
1405
    files: list[str] | str | None = None,
1406
    dry: bool = False,
1407
    bandit: bool = False,
1408
    skip_mypy: bool = False,
1409
    dmypy: bool = False,
1410
    tool: str = ToolOption.default,
1411
    up: bool = False,
1412
    sim: bool = True,
1413
    strict: bool = False,
1414
    ty: bool = False,
1415
) -> None:
1416
    LintCode(
5 ✔
1417
        files,
1418
        check_only=True,
1419
        _exit=True,
1420
        dry=dry,
1421
        bandit=bandit,
1422
        skip_mypy=skip_mypy,
1423
        dmypy=dmypy,
1424
        tool=tool,
1425
        up=up,
1426
        sim=sim,
1427
        strict=strict,
1428
        ty=ty,
1429
    ).run()
1430

1431

1432
def _should_bandit() -> bool:
5 ✔
1433
    if v := os.getenv("FASTDEVCLI_BANDIT"):
×
1434
        return _convert_bool(v) or True
×
1435
    toml_text = Project.load_toml_text()
×
1436
    lines = toml_text.splitlines()
×
1437
    return any(i.startswith("[tool.bandit") for i in lines)
×
1438

1439

1440
@cli.command(name="lint")
5 ✔
1441
def make_style(
5 ✔
1442
    files: list[str] | None = typer.Argument(default=None),  # noqa:B008
1443
    check_only: bool = Option(False, "--check-only", "-c"),
1444
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1445
    prefix: bool = Option(
1446
        False,
1447
        "--prefix",
1448
        help="Run lint command with tool prefix, e.g.: pdm run ruff ...",
1449
    ),
1450
    skip_mypy: bool = Option(False, "--skip-mypy"),
1451
    use_dmypy: bool = Option(
1452
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
1453
    ),
1454
    tool: str = ToolOption,
1455
    dry: bool = DryOption,
1456
    up: bool = Option(False, help="Whether ruff check with --extend-select=UP"),
1457
    sim: bool = Option(True, help="Whether ruff check with --extend-select=SIM"),
1458
    strict: bool = Option(False, help="Whether run mypy with --strict"),
1459
    ty: bool = Option(False, help="Whether use ty instead of mypy"),
1460
    fix: bool | None = Option(None, help="Whether ruff check with --fix"),
1461
    auto_bandit: bool | None = Option(
1462
        None, help="Whether to run bandit if `[tool.bandit]` in pyproject.toml"
1463
    ),
1464
) -> None:
1465
    """Run: ruff check/format to reformat code and then mypy to check"""
1466
    if getattr(files, "default", files) is None:
5 ✔
1467
        files = ["."]
5 ✔
1468
    elif isinstance(files, str):
5 ✔
1469
        files = [files]
5 ✔
1470
    skip = _ensure_bool(skip_mypy)
5 ✔
1471
    dmypy = _ensure_bool(use_dmypy)
5 ✔
1472
    bandit = _ensure_bool(bandit) or (
5 ✔
1473
        auto_bandit is not None and _ensure_bool(auto_bandit) and _should_bandit()
1474
    )
1475
    tool = _ensure_str(tool) or ""
5 ✔
1476
    up = _ensure_bool(up)
5 ✔
1477
    sim = _ensure_bool(sim)
5 ✔
1478
    strict = _ensure_bool(strict)
5 ✔
1479
    kwargs = {"dry": dry, "skip_mypy": skip, "dmypy": dmypy, "bandit": bandit}
5 ✔
1480
    if _ensure_bool(check_only):
5 ✔
1481
        run = check
5 ✔
1482
    else:
1483
        prefix = _ensure_bool(prefix)
5 ✔
1484
        if fix is None or not isinstance(fix, bool):
5 ✔
1485
            fix = load_bool("FASTDEVCLI_FIX", True)
5 ✔
1486
        run = functools.partial(lint, prefix=prefix, fix=fix)
5 ✔
1487
    run(files, tool=tool, up=up, sim=sim, strict=strict, ty=ty, **kwargs)
5 ✔
1488

1489

1490
@cli.command(name="check")
5 ✔
1491
def only_check(
5 ✔
1492
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1493
    skip_mypy: bool = Option(False, "--skip-mypy"),
1494
    dry: bool = DryOption,
1495
    up: bool = Option(False, help="Whether ruff check with --extend-select=UP"),
1496
    sim: bool = Option(True, help="Whether ruff check with --extend-select=SIM"),
1497
    strict: bool = Option(False, help="Whether run mypy with --strict"),
1498
    ty: bool = Option(False, help="Whether use ty instead of mypy"),
1499
) -> None:
1500
    """Check code style without reformat"""
1501
    bandit = _ensure_bool(bandit)
5 ✔
1502
    up = _ensure_bool(up)
5 ✔
1503
    sim = _ensure_bool(sim)
5 ✔
1504
    skip_mypy = _ensure_bool(skip_mypy)
5 ✔
1505
    check(dry=dry, bandit=bandit, skip_mypy=skip_mypy, up=up, sim=sim, ty=ty)
5 ✔
1506

1507

1508
class Sync(DryRun):
5 ✔
1509
    def __init__(
5 ✔
1510
        self, filename: str, extras: str, save: bool, dry: bool = False
1511
    ) -> None:
1512
        self.filename = filename
5 ✔
1513
        self.extras = extras
5 ✔
1514
        self._save = save
5 ✔
1515
        super().__init__(dry=dry)
5 ✔
1516

1517
    def gen(self) -> str:
5 ✔
1518
        extras, save = self.extras, self._save
5 ✔
1519
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5 ✔
1520
        if not (tool := Project.get_manage_tool()):
5 ✔
1521
            if should_remove or not is_venv():
5 ✔
1522
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5 ✔
1523
            return f"python -m pip install -r {self.filename}"
5 ✔
1524
        prefix = ""
5 ✔
1525
        if not is_venv():
5 ✔
1526
            prefix = f"{tool} run " + "--no-sync " * (tool == "uv")
5 ✔
1527
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5 ✔
1528
        export_cmd = "uv export --no-hashes --all-extras --all-groups --frozen"
5 ✔
1529
        if tool in ("poetry", "pdm"):
5 ✔
1530
            export_cmd = f"{tool} export --without-hashes --with=dev"
5 ✔
1531
            if tool == "poetry":
5 ✔
1532
                ensure_pip = ""
5 ✔
1533
                if not UpgradeDependencies.should_with_dev():
5 ✔
1534
                    export_cmd = export_cmd.replace(" --with=dev", "")
5 ✔
1535
                if extras and isinstance(extras, str | list):
5 ✔
1536
                    export_cmd += f" --{extras=}".replace("'", '"')
5 ✔
1537
            elif check_call(prefix + "python -m pip --version"):
×
1538
                ensure_pip = ""
×
1539
        elif check_call(prefix + "python -m pip --version"):
5 ✔
1540
            ensure_pip = ""
5 ✔
1541
        install_cmd = (
5 ✔
1542
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
1543
        )
1544
        if should_remove and not save:
5 ✔
1545
            install_cmd += " && rm -f {0}"
5 ✔
1546
        return install_cmd.format(self.filename, prefix, export_cmd)
5 ✔
1547

1548

1549
@cli.command()
5 ✔
1550
def sync(
5 ✔
1551
    filename: str = "dev_requirements.txt",
1552
    extras: str = Option("", "--extras", "-E"),
1553
    save: bool = Option(
1554
        False, "--save", "-s", help="Whether save the requirement file"
1555
    ),
1556
    dry: bool = DryOption,
1557
) -> None:
1558
    """Export dependencies by poetry to a txt file then install by pip."""
1559
    Sync(filename, extras, save, dry=dry).run()
5 ✔
1560

1561

1562
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5 ✔
1563
    for name in ("test.sh", "test.py"):
5 ✔
1564
        if (file := path / name).exists():
5 ✔
1565
            return file
5 ✔
1566
    return None
5 ✔
1567

1568

1569
def test(dry: bool, ignore_script: bool = False) -> None:
5 ✔
1570
    cwd = Path.cwd()
5 ✔
1571
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5 ✔
1572
    script_dir = root / "scripts"
5 ✔
1573
    if not _ensure_bool(ignore_script) and (
5 ✔
1574
        test_script := _should_run_test_script(script_dir)
1575
    ):
1576
        cmd = test_script.relative_to(root).as_posix()
5 ✔
1577
        if test_script.suffix == ".py":
5 ✔
1578
            cmd = "python " + cmd
5 ✔
1579
        if cwd != root:
5 ✔
1580
            cmd = f"cd {root} && " + cmd
5 ✔
1581
    else:
1582
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5 ✔
1583
        if not is_venv() or not check_call("coverage --version"):
5 ✔
1584
            sep = " && "
5 ✔
1585
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5 ✔
1586
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5 ✔
1587
    exit_if_run_failed(cmd, dry=dry)
5 ✔
1588

1589

1590
@cli.command(name="test")
5 ✔
1591
def coverage_test(
5 ✔
1592
    dry: bool = DryOption,
1593
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1594
) -> None:
1595
    """Run unittest by pytest and report coverage"""
1596
    return test(dry, ignore_script)
5 ✔
1597

1598

1599
class Publish:
5 ✔
1600
    class CommandEnum(StrEnum):
5 ✔
1601
        poetry = "poetry publish --build"
5 ✔
1602
        pdm = "pdm publish"
5 ✔
1603
        uv = "uv build && uv publish"
5 ✔
1604
        twine = "python -m build && twine upload"
5 ✔
1605

1606
    @classmethod
5 ✔
1607
    def gen(cls, tool: str | None, verbose: bool) -> str:
5 ✔
1608
        if tool == "auto":
5 ✔
1609
            tool = Project.get_manage_tool() or ""
5 ✔
1610
        if tool:
5 ✔
1611
            if tool == "uv":
5 ✔
1612
                envs = ["UV_PUBLISH_INDEX", "UV_PUBLISH_URL", "UV_PUBLISH_TOKEN"]
5 ✔
1613
                if not any(os.getenv(i) for i in envs):
5 ✔
1614
                    if verbose:
5 ✔
1615
                        yellow_warn(f"Skip uv publish as envs ({envs}) not set")
×
1616
                    return "uv build"
5 ✔
1617
            elif tool == "pdm":
5 ✔
1618
                env = "PDM_PUBLISH_REPO"
×
1619
                if not os.getenv(env):
×
1620
                    if verbose:
×
1621
                        yellow_warn(f"Skip pdm publish as env ({env}) not set")
×
1622
                    return "pdm build"
×
1623
            return cls.CommandEnum[tool]
5 ✔
1624
        return cls.CommandEnum.twine
5 ✔
1625

1626

1627
@cli.command()
5 ✔
1628
def upload(
5 ✔
1629
    verbose: bool = False,
1630
    tool: str = ToolOption,
1631
    dry: bool = DryOption,
1632
) -> None:
1633
    """Shortcut for package publish"""
1634
    cmd = Publish.gen(_ensure_str(tool), verbose)
5 ✔
1635
    exit_if_run_failed(cmd, dry=dry)
5 ✔
1636

1637

1638
def should_use_just() -> bool:
5 ✔
1639
    if shutil.which("just") is None or load_bool("FASTDEVCLI_IGNORE_JUST_DEV"):
5 ✔
1640
        return False
5 ✔
1641
    d = Path.cwd()
5 ✔
1642
    for _ in range(5):
5 ✔
1643
        f = d / "justfile"
5 ✔
1644
        if f.exists():
5 ✔
1645
            return _prefer_just_dev(f)
5 ✔
1646
        if d.joinpath("pyproject.toml").exists():
×
1647
            break
×
NEW
1648
        d = d.parent
×
UNCOV
1649
    return False
×
1650

1651

1652
def _prefer_just_dev(f: Path) -> bool:
5 ✔
1653
    text = f.read_text(encoding="utf-8")
5 ✔
1654
    lines = text.splitlines()
5 ✔
1655
    dev_recipe = "dev *args:"
5 ✔
1656
    re_import = re.compile(r"import[?]? ")
5 ✔
1657
    has_import = False
5 ✔
1658
    total = len(lines)
5 ✔
1659
    for i, line in enumerate(lines):
5 ✔
1660
        if line.startswith(dev_recipe):
5 ✔
1661
            # Avoid cycle callback
NEW
1662
            command_lines = []
×
NEW
1663
            for j in range(i + 1, total):
×
NEW
1664
                try:
×
NEW
1665
                    s = lines[j]
×
NEW
1666
                except IndexError:
×
NEW
1667
                    break
×
NEW
1668
                if not s.startswith(" "):
×
NEW
1669
                    break
×
NEW
1670
                command_lines.append(s)
×
NEW
1671
            if not command_lines:  # Invalid justfile
×
NEW
1672
                return False
×
NEW
1673
            return all("fast dev" not in i for i in command_lines)
×
1674
        elif not has_import and re_import.match(line):
5 ✔
1675
            has_import = True
5 ✔
1676
    if has_import:
5 ✔
1677
        recipes = capture_cmd_output("just --list").splitlines()
5 ✔
1678
        for recipe in recipes:
5 ✔
1679
            recipe = recipe.strip()
5 ✔
1680
            if recipe.startswith(dev_recipe):
5 ✔
1681
                return "fast dev" not in recipe
×
1682
    return False
5 ✔
1683

1684

1685
def _parse_serve_file(
5 ✔
1686
    uvicorn: bool | None, filename: str, cmd: str, args: list[str]
1687
) -> str:
1688
    if m := re.search(r"(.*):(\d+)$", filename):
5 ✔
1689
        h, p = m.group(1), m.group(2)
5 ✔
1690
        if h and "--host" not in str(args):
5 ✔
1691
            if h == "0":
5 ✔
1692
                args.append("--host=0.0.0.0")
5 ✔
1693
            else:
1694
                args.append(f"--host={h}")
5 ✔
1695
        args.append(f"--port={p}")
5 ✔
1696
        if uvicorn:
5 ✔
1697
            # TODO: load [tool.fastapi]
1698
            p = Path("main.py")
×
1699
            if p.exists():
×
1700
                cmd += " main:app"
×
1701
            elif Path("app", p.name).exists():
×
1702
                cmd += " app.main:app"
×
1703
            elif Path("app.py").exists():
×
1704
                cmd += " app:app"
×
1705
        return cmd
5 ✔
1706
    if uvicorn and ((filepath := Path(filename)).is_file() or filepath.suffix == ".py"):
5 ✔
1707
        filename = filepath.stem + ":app"
5 ✔
1708
        parent_names = [j for i in filepath.parents if (j := i.name)]
5 ✔
1709
        if parent_names:
5 ✔
1710
            filename = ".".join([*parent_names[::-1], filename])
5 ✔
1711
    cmd += " " + filename
5 ✔
1712
    return cmd
5 ✔
1713

1714

1715
def _runserver(
5 ✔
1716
    uvicorn: bool | None,
1717
    host: OptionInfo | str | None,
1718
    port: OptionInfo | int | None,
1719
    file: ArgumentInfo | str | None,
1720
) -> tuple[str, list[str]]:
1721
    cmd = "uvicorn" if uvicorn else "fastapi dev"
5 ✔
1722
    args = []
5 ✔
1723
    if (host := getattr(host, "default", host)) and host not in (
5 ✔
1724
        "localhost",
1725
        "127.0.0.1",
1726
    ):
1727
        args.append(f"--host={host}")
5 ✔
1728
    no_port_yet = True
5 ✔
1729
    if file is not None:
5 ✔
1730
        filename = str(file)
5 ✔
1731
        try:
5 ✔
1732
            port = int(filename)
5 ✔
1733
        except ValueError:
5 ✔
1734
            cmd = _parse_serve_file(uvicorn, filename, cmd, args)
5 ✔
1735
        else:
1736
            if port != 8000:
5 ✔
1737
                args.append(f"--port={port}")
5 ✔
1738
                no_port_yet = False
5 ✔
1739
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5 ✔
1740
        args.append(f"--port={port}")
5 ✔
1741
    if shutil.which("pdm") is not None:
5 ✔
1742
        cmd = "pdm run " + cmd
5 ✔
1743
    return cmd, args
5 ✔
1744

1745

1746
def dev(
5 ✔
1747
    port: int | None | OptionInfo,
1748
    host: str | None | OptionInfo,
1749
    fastapi: bool | None = None,
1750
    uvicorn: bool | None = None,
1751
    prod: bool | None = None,
1752
    reload: bool | None = None,
1753
    just: bool | None = None,
1754
    file: str | None | ArgumentInfo = None,
1755
    dry: bool = False,
1756
) -> None:
1757
    if just is not False and should_use_just():
5 ✔
1758
        args = [i for i in sys.argv[2:] if i != "--dry"]
×
1759
        cmd = "just dev"
×
1760
    else:
1761
        cmd, args = _runserver(uvicorn, host, port, file)
5 ✔
1762
    if args:
5 ✔
1763
        cmd += " " + " ".join(args)
5 ✔
1764
    exit_if_run_failed(cmd, dry=dry)
5 ✔
1765

1766

1767
@cli.command(name="dev")
5 ✔
1768
def runserver(
5 ✔
1769
    file_or_port: str | None = typer.Argument(default=None),
1770
    port: int | None = Option(None, "-p", "--port"),
1771
    host: str | None = Option(None, "-h", "--host"),
1772
    fastapi: bool | None = None,
1773
    uvicorn: bool | None = None,
1774
    prod: bool | None = None,
1775
    reload: bool | None = None,
1776
    just: bool | None = None,
1777
    dry: bool = DryOption,
1778
) -> None:
1779
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1780
    f = functools.partial(
5 ✔
1781
        dev, port, host, fastapi, uvicorn, prod, reload, just, dry=dry
1782
    )
1783
    if getattr(file_or_port, "default", file_or_port):
5 ✔
1784
        f(file=file_or_port)
5 ✔
1785
    else:
1786
        f()
5 ✔
1787

1788

1789
@cli.command(name="exec")
5 ✔
1790
def run_by_subprocess(cmd: str, dry: bool = DryOption) -> None:
5 ✔
1791
    """Run cmd by subprocess, auto set shell=True when cmd contains '|>'"""
1792
    try:
5 ✔
1793
        rc = run_and_echo(cmd, verbose=True, dry=_ensure_bool(dry))
5 ✔
1794
    except FileNotFoundError as e:
5 ✔
1795
        command = cmd.split()[0]
5 ✔
1796
        if e.filename == command or (
5 ✔
1797
            e.filename is None and "系统找不到指定的文件" in str(e)
1798
        ):
1799
            echo(f"Command not found: {command}")
5 ✔
1800
            raise Exit(1) from None
5 ✔
1801
        raise
×
1802
    else:
1803
        if rc:
5 ✔
1804
            raise Exit(rc)
5 ✔
1805

1806

1807
class MakeDeps(DryRun):
5 ✔
1808
    def __init__(
5 ✔
1809
        self,
1810
        tool: str,
1811
        prod: bool = False,
1812
        dry: bool = False,
1813
        active: bool = False,
1814
        inexact: bool = False,
1815
        no_dev: bool = False,
1816
        verbose: bool = False,
1817
        frozen: bool = False,
1818
        no_extra: list[str] | None = None,
1819
        no_group: list[str] | None = None,
1820
    ) -> None:
1821
        self._tool = tool
5 ✔
1822
        self._prod = prod
5 ✔
1823
        self._active = active or load_bool("FASTDEVCLI_DEPS_ACTIVE")
5 ✔
1824
        self._inexact = inexact or load_bool("FASTDEVCLI_DEPS_INEXACT")
5 ✔
1825
        self._verbose = verbose
5 ✔
1826
        self._frozen = frozen
5 ✔
1827
        self._no_dev = no_dev
5 ✔
1828
        self._no_extra = no_extra
5 ✔
1829
        self._no_group = no_group
5 ✔
1830
        super().__init__(dry=dry)
5 ✔
1831

1832
    def should_ensure_pip(self) -> bool:
5 ✔
1833
        return True
5 ✔
1834

1835
    def should_upgrade_pip(self) -> bool:
5 ✔
1836
        return True
5 ✔
1837

1838
    def get_groups(self) -> list[str]:
5 ✔
1839
        if self._prod:
5 ✔
1840
            return []
5 ✔
1841
        return ["dev"]
5 ✔
1842

1843
    def gen(self) -> str:
5 ✔
1844
        cmd = self._gen()
5 ✔
1845
        if self._verbose:
5 ✔
1846
            cmd += " --verbose"
×
1847
        if self._no_dev:
5 ✔
1848
            opt = " --no-dev"
×
1849
            if opt not in cmd:
×
1850
                cmd += opt
×
1851
        if self._no_extra:
5 ✔
1852
            cmd += " " + " ".join(f"--no-extra {i}" for i in self._no_extra)
×
1853
        if self._no_group:
5 ✔
1854
            cmd += " " + " ".join(f"--no-group {i}" for i in self._no_group)
×
1855
        if self._frozen:
5 ✔
1856
            cmd += " --frozen"
×
1857
        if opts := os.getenv("FASTDEVCLI_DEPS_OPTS"):
5 ✔
1858
            cmd += " " + opts.strip()
×
1859
        return cmd
5 ✔
1860

1861
    def get_package_name(self) -> str:
5 ✔
1862
        with contextlib.suppress(FileNotFoundError, KeyError):
5 ✔
1863
            try:
5 ✔
1864
                toml_text = Project.load_toml_text()
5 ✔
1865
            except EnvError:
×
1866
                return ""
×
1867
            doc = tomllib.loads(toml_text)
5 ✔
1868
            tool_section = doc["tool"]
5 ✔
1869
            uv_package = tool_section.get("uv", {}).get("package")
5 ✔
1870
            if uv_package is not None:
5 ✔
1871
                if not uv_package:
×
1872
                    return ""
×
1873
            else:
1874
                match doc["build-system"]["build-backend"]:
5 ✔
1875
                    case "pdm.backend":
5 ✔
1876
                        if not tool_section.get("pdm", {}).get("distribution", True):
5 ✔
1877
                            return ""
×
1878
                    case x if x.startswith("poetry"):
×
1879
                        if not tool_section.get("poetry", {}).get("package-mode", True):
×
1880
                            return ""
×
1881
            return cast(str, doc["project"]["name"])
5 ✔
1882
        return ""
×
1883

1884
    def _gen(self) -> str:
5 ✔
1885
        if self._tool == "pdm":
5 ✔
1886
            return "pdm install --frozen " + ("--prod" if self._prod else "-G :all")
5 ✔
1887
        elif self._tool == "uv":
5 ✔
1888
            uv_sync = "uv sync"
5 ✔
1889
            if project := self.get_package_name():
5 ✔
1890
                uv_sync += f" --reinstall-package={project}"
5 ✔
1891
            uv_sync += " --inexact" * self._inexact + " --active" * self._active
5 ✔
1892
            return uv_sync + (
5 ✔
1893
                " --no-dev" if self._prod else " --all-extras --all-groups"
1894
            )
1895
        elif self._tool == "poetry":
5 ✔
1896
            return "poetry install " + (
5 ✔
1897
                "--only=main" if self._prod else "--all-extras --all-groups"
1898
            )
1899
        else:
1900
            cmd = "python -m pip install -e ."
5 ✔
1901
            if gs := self.get_groups():
5 ✔
1902
                cmd += " " + " ".join(f"--group {g}" for g in gs)
5 ✔
1903
            upgrade = "python -m pip install --upgrade pip"
5 ✔
1904
            if self.should_ensure_pip():
5 ✔
1905
                cmd = f"python -m ensurepip && {upgrade} && {cmd}"
5 ✔
1906
            elif self.should_upgrade_pip():
5 ✔
1907
                cmd = f"{upgrade} && {cmd}"
5 ✔
1908
            return cmd
5 ✔
1909

1910

1911
@cli.command(name="deps")
5 ✔
1912
def make_deps(
5 ✔
1913
    prod: bool = Option(
1914
        False,
1915
        "--prod",
1916
        help="Only instead production dependencies.",
1917
    ),
1918
    tool: str = ToolOption,
1919
    use_uv: bool = Option(False, "--uv", help="Use `uv` to install deps"),
1920
    use_pdm: bool = Option(False, "--pdm", help="Use `pdm` to install deps"),
1921
    use_pip: bool = Option(False, "--pip", help="Use `pip` to install deps"),
1922
    use_poetry: bool = Option(False, "--poetry", help="Use `poetry` to install deps"),
1923
    active: bool = Option(
1924
        False, help="Add `--active` to uv sync command(Only work for uv project)"
1925
    ),
1926
    inexact: bool = Option(
1927
        False, help="Add `--inexact` to uv sync command(Only work for uv project)"
1928
    ),
1929
    no_dev: bool = Option(False, "--no-dev"),
1930
    no_extra: Annotated[list[str] | None, Option()] = None,
1931
    no_group: Annotated[list[str] | None, Option()] = None,
1932
    frozen: bool = Option(False, "--frozen", "--frozen-lockfile", "--no-lock"),
1933
    verbose: bool = Option(False, "--verbose"),
1934
    dry: bool = DryOption,
1935
) -> None:
1936
    """Run: ruff check/format to reformat code and then mypy to check"""
1937
    if use_uv + use_pdm + use_pip + use_poetry > 1:
×
1938
        raise typer.BadParameter(
×
1939
            "can only choose one",
1940
            param_hint=("--uv", "--pdm", "--pip", "--poetry"),
1941
        )
1942
    if use_uv:
×
1943
        tool = "uv"
×
1944
    elif use_pdm:
×
1945
        tool = "pdm"
×
1946
    elif use_pip:
×
1947
        tool = "pip"
×
1948
    elif use_poetry:
×
1949
        tool = "poetry"
×
1950
    elif tool == ToolOption.default:
×
1951
        tool = Project.get_manage_tool(cache=True) or "pip"
×
1952
    bool_opts = {
×
1953
        "active": active,
1954
        "inexact": inexact,
1955
        "no_dev": no_dev,
1956
        "verbose": verbose,
1957
        "frozen": frozen,
1958
        "dry": dry,
1959
    }
1960
    MakeDeps(tool, prod, no_extra=no_extra, no_group=no_group, **bool_opts).run()
×
1961

1962

1963
class UvPypi(DryRun):
5 ✔
1964
    PYPI = "https://pypi.org/simple"
5 ✔
1965
    HOST = "https://files.pythonhosted.org"
5 ✔
1966

1967
    def __init__(
5 ✔
1968
        self,
1969
        lock_file: Path,
1970
        dry: bool,
1971
        verbose: bool,
1972
        quiet: bool,
1973
        slim: bool = False,
1974
        reverse: bool = False,
1975
    ) -> None:
1976
        super().__init__(dry=dry)
5 ✔
1977
        self.lock_file = lock_file
5 ✔
1978
        self._verbose = _ensure_bool(verbose)
5 ✔
1979
        self._quiet = _ensure_bool(quiet)
5 ✔
1980
        self._slim = _ensure_bool(slim)
5 ✔
1981
        self._reverse = _ensure_bool(reverse)
5 ✔
1982

1983
    def run(self) -> None:
5 ✔
1984
        try:
5 ✔
1985
            rc = self.update_lock(
5 ✔
1986
                self.lock_file, self._verbose, self._quiet, self._slim, self._reverse
1987
            )
1988
        except ValueError as e:
×
1989
            secho(str(e), fg=typer.colors.RED)
×
1990
            raise Exit(1) from e
×
1991
        else:
1992
            if rc != 0:
5 ✔
1993
                raise Exit(rc)
5 ✔
1994

1995
    @staticmethod
5 ✔
1996
    def get_target_content(
5 ✔
1997
        text: str, verbose: bool, target_registry: str, target_host: str
1998
    ) -> str | None:
1999
        registry_pattern = r'(registry = ")(.*?)"'
5 ✔
2000
        registry_urls = {i[1] for i in re.findall(registry_pattern, text)}
5 ✔
2001
        download_pattern = r'(url = ")(https?://.*?)(/packages/.*?\.)(gz|whl|zip)"'
5 ✔
2002
        download_hosts = {i[1] for i in re.findall(download_pattern, text)}
5 ✔
2003
        if not registry_urls:
5 ✔
2004
            raise ValueError(
×
2005
                f"Failed to find pattern {registry_pattern!r} in uv lock file"
2006
            )
2007

2008
        def replace_registry(s: str) -> str:
5 ✔
2009
            return re.sub(registry_pattern, rf'\1{target_registry}"', s)
5 ✔
2010

2011
        def replace_host(s: str) -> str:
5 ✔
2012
            return re.sub(download_pattern, rf'\1{target_host}\3\4"', s)
5 ✔
2013

2014
        if len(registry_urls) == 1:
5 ✔
2015
            current_registry = registry_urls.pop()
5 ✔
2016
            if current_registry == target_registry:
5 ✔
2017
                if download_hosts == {target_host}:
5 ✔
2018
                    return None
5 ✔
2019
            else:
2020
                text = replace_registry(text)
5 ✔
2021
                if verbose:
5 ✔
2022
                    echo(f"{current_registry} --> {target_registry}")
5 ✔
2023
        else:
2024
            # TODO: ask each one to confirm replace
2025
            text = replace_registry(text)
×
2026
            if verbose:
×
2027
                for current_registry in sorted(registry_urls):
×
2028
                    echo(f"{current_registry} --> {target_registry}")
×
2029
        if len(download_hosts) == 1:
5 ✔
2030
            current_host = download_hosts.pop()
5 ✔
2031
            if current_host != target_host:
5 ✔
2032
                text = replace_host(text)
5 ✔
2033
                if verbose:
5 ✔
2034
                    echo(f"{current_host} --> {target_host}")
5 ✔
2035
        elif download_hosts:
×
2036
            # TODO: ask each one to confirm replace
2037
            text = replace_host(text)
×
2038
            if verbose:
×
2039
                for current_host in sorted(download_hosts):
×
2040
                    echo(f"{current_host} --> {target_host}")
×
2041
        return text
5 ✔
2042

2043
    @classmethod
5 ✔
2044
    def update_lock(
5 ✔
2045
        cls,
2046
        p: Path,
2047
        verbose: bool,
2048
        quiet: bool,
2049
        slim: bool = False,
2050
        reverse: bool = False,
2051
    ) -> int:
2052
        text = p.read_text("utf-8")
5 ✔
2053
        target_register, target_host = cls.PYPI, cls.HOST
5 ✔
2054
        if reverse:
5 ✔
2055
            try:
5 ✔
2056
                target_register, target_host = cls.get_register_from_uv_config()
5 ✔
2057
            except FileNotFoundError:
5 ✔
2058
                if verbose:
5 ✔
2059
                    echo("Skip register reverse as global uv config file not found.")
5 ✔
2060
                if quiet:
5 ✔
2061
                    return 0
5 ✔
2062
                return 1
×
2063
        new_text = cls.get_target_content(text, verbose, target_register, target_host)
5 ✔
2064
        if new_text is None:
5 ✔
2065
            if verbose:
5 ✔
2066
                echo(f"Registry of {p} is {target_register}, no need to change.")
5 ✔
2067
            return 0
5 ✔
2068
        return cls.slim_and_write(new_text, slim, p, verbose, quiet)
5 ✔
2069

2070
    @classmethod
5 ✔
2071
    def get_register_from_uv_config(cls) -> tuple[str, str]:
5 ✔
2072
        config_file = cls.get_uv_config_file()
5 ✔
2073
        text = config_file.read_text("utf-8")
5 ✔
2074
        doc = tomllib.loads(text)
5 ✔
2075
        index_url = doc["index"][0]["url"]
5 ✔
2076
        return index_url, index_url.replace("/simple", "").rstrip("/")
5 ✔
2077

2078
    @staticmethod
5 ✔
2079
    def get_uv_config_file() -> Path:
5 ✔
2080
        config_dir = "AppData/Roaming" if is_windows() else ".config"
5 ✔
2081
        return Path.home() / config_dir / "uv/uv.toml"
5 ✔
2082

2083
    @staticmethod
5 ✔
2084
    def slim_and_write(
5 ✔
2085
        text: str, slim: bool, p: Path, verbose: bool, quiet: bool
2086
    ) -> int:
2087
        if slim:
5 ✔
2088
            pattern = r', size = \d+, upload-time = ".*?"'
5 ✔
2089
            text = re.sub(pattern, "", text)
5 ✔
2090
        size = p.write_text(text, encoding="utf-8")
5 ✔
2091
        if verbose:
5 ✔
2092
            echo(f"Updated {p} with {size} bytes.")
5 ✔
2093
        if quiet:
5 ✔
2094
            return 0
5 ✔
2095
        return 1
5 ✔
2096

2097

2098
@cli.command()
5 ✔
2099
def pypi(
5 ✔
2100
    file: str | None = typer.Argument(default=None),
2101
    dry: bool = DryOption,
2102
    verbose: bool = False,
2103
    quiet: bool = False,
2104
    slim: bool = False,
2105
    reverse: bool = False,
2106
) -> None:
2107
    """Change registry of uv.lock to be pypi.org"""
2108
    if not (p := Path(_ensure_str(file) or "uv.lock")).exists() and not (
5 ✔
2109
        (p := Project.get_work_dir() / p.name).exists()
2110
    ):
2111
        yellow_warn(f"{p.name!r} not found!")
5 ✔
2112
        return
5 ✔
2113
    UvPypi(p, dry, verbose, quiet, slim, reverse).run()
5 ✔
2114

2115

2116
def version_callback(value: bool) -> None:
5 ✔
2117
    if value:
5 ✔
2118
        echo("Fast Dev Cli Version: " + typer.style(__version__, bold=True))
5 ✔
2119
        raise Exit()
5 ✔
2120

2121

2122
@cli.callback()
2123
def common(
2124
    version: bool = Option(
2125
        None,
2126
        "--version",
2127
        "-V",
2128
        callback=version_callback,
2129
        is_eager=True,
2130
        help="Show the version of this tool",
2131
    ),
2132
) -> None: ...
2133

2134

2135
def main() -> None:
5 ✔
2136
    cli()
5 ✔
2137

2138

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

© 2026 Coveralls, Inc