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

waketzheng / fast-dev-cli / 30735429562

02 Aug 2026 06:10AM UTC coverage: 81.947% (-0.6%) from 82.568%
30735429562

push

github

waketzheng
feat: auto load fastapi entrypoint for `fast dev --uvicorn`

2 of 20 new or added lines in 1 file covered. (10.0%)

1153 of 1407 relevant lines covered (81.95%)

4.09 hits per line

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

81.93
/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
def _quote_shell_arg(value: str | Path) -> str:
5✔
382
    text = str(value)
5✔
383
    if not is_windows():
5✔
384
        return shlex.quote(text)
5✔
385
    if text and not re.search(r'[\s"&|<>^()%!]', text):
×
386
        return text
×
387

388
    # Quote for the Windows C runtime while keeping cmd.exe metacharacters inert.
389
    result = ['"']
×
390
    backslashes = 0
×
391
    for char in text:
×
392
        if char == "\\":
×
393
            backslashes += 1
×
394
        elif char == '"':
×
395
            result.append("\\" * (backslashes * 2 + 1))
×
396
            result.append(char)
×
397
            backslashes = 0
×
398
        else:
399
            result.append("\\" * backslashes)
×
400
            result.append(char)
×
401
            backslashes = 0
×
402
    result.append("\\" * (backslashes * 2))
×
403
    result.append('"')
×
404
    return "".join(result)
×
405

406

407
def _join_shell_args(args: list[str]) -> str:
5✔
408
    return " ".join(_quote_shell_arg(arg) for arg in args)
5✔
409

410

411
class DryRun:
5✔
412
    def __init__(self, _exit: bool = False, dry: bool = False) -> None:
5✔
413
        self.dry = _ensure_bool(dry)
5✔
414
        self._exit = _exit
5✔
415

416
    def gen(self) -> str:
5✔
417
        raise NotImplementedError
5✔
418

419
    def run(self) -> None:
5✔
420
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
5✔
421

422

423
class BumpUp(DryRun):
5✔
424
    class PartChoices(StrEnum):
5✔
425
        patch = "patch"
5✔
426
        minor = "minor"
5✔
427
        major = "major"
5✔
428

429
    def __init__(
5✔
430
        self,
431
        commit: bool,
432
        part: str,
433
        filename: str | None = None,
434
        dry: bool = False,
435
        no_sync: bool = False,
436
        emoji: bool | None = None,
437
    ) -> None:
438
        self.commit = commit
5✔
439
        self.part = part
5✔
440
        if filename is None:
5✔
441
            try:
5✔
442
                filename = self.parse_filename()
5✔
443
            except EnvError:
5✔
444
                if (res := _get_frontend_version()) is not None:
×
445
                    filename = res[0].name
×
446
                else:
447
                    raise
×
448
        self.filename = filename
5✔
449
        self._no_sync = no_sync
5✔
450
        self._emoji = emoji
5✔
451
        super().__init__(dry=dry)
5✔
452

453
    @staticmethod
5✔
454
    def get_last_commit_message(raises: bool = False) -> str:
5✔
455
        cmd = 'git show --pretty=format:"%s" -s HEAD'
5✔
456
        return capture_cmd_output(cmd, raises=raises)
5✔
457

458
    @classmethod
5✔
459
    def should_add_emoji(cls) -> bool:
5✔
460
        """
461
        If last commit message is startswith emoji,
462
        add a ⬆️ flag at the prefix of bump up commit message.
463
        """
464
        try:
5✔
465
            first_char = cls.get_last_commit_message(raises=True)[0]
5✔
466
        except (IndexError, ShellCommandError):
5✔
467
            return False
5✔
468
        else:
469
            return is_emoji(first_char)
5✔
470

471
    @staticmethod
5✔
472
    def parse_dynamic_version(
5✔
473
        toml_text: str | None,
474
        context: dict[str, Any],
475
        work_dir: Path | None = None,
476
    ) -> str | None:
477
        if work_dir is None:
5✔
478
            work_dir = Project.get_work_dir()
5✔
479
        for tool in ("pdm", "hatch"):
5✔
480
            with contextlib.suppress(KeyError):
5✔
481
                version_path = cast(str, context["tool"][tool]["version"]["path"])
5✔
482
                if (
5✔
483
                    Path(version_path).exists()
484
                    or work_dir.joinpath(version_path).exists()
485
                ):
486
                    return version_path
5✔
487
        # e.g.: version = { source = "file", path = "fast_dev_cli/__init__.py" }
488
        v_key = "version = "
5✔
489
        p_key = 'path = "'
5✔
490
        if toml_text is None:
5✔
491
            toml_text = Project.load_toml_text()
×
492
        for line in toml_text.splitlines():
5✔
493
            if not line.startswith(v_key):
×
494
                continue
×
495
            if p_key in (value := line.split(v_key, 1)[-1].split("#")[0]):
×
496
                filename = value.split(p_key, 1)[-1].split('"')[0]
×
497
                if work_dir.joinpath(filename).exists():
×
498
                    return filename
×
499
        return None
5✔
500

501
    @classmethod
5✔
502
    def parse_filename(
5✔
503
        cls,
504
        toml_text: str | None = None,
505
        work_dir: Path | None = None,
506
        package_name: str | None = None,
507
        context: dict[str, Any] | None = None,
508
    ) -> str:
509
        if context is None:
5✔
510
            if toml_text is None:
5✔
511
                toml_text = Project.load_toml_text()
5✔
512
            context = tomllib.loads(toml_text)
5✔
513
        is_dynamic_version = False
5✔
514
        with contextlib.suppress(KeyError):
5✔
515
            if "version" in context["project"]["dynamic"]:
5✔
516
                is_dynamic_version = True
5✔
517
        if is_dynamic_version:
5✔
518
            if filename := cls.parse_dynamic_version(toml_text, context, work_dir):
5✔
519
                return filename
5✔
520
            yellow_warn("Failed to find version file for this dynamic version project.")
×
521
        by_version_plugin = False
5✔
522
        try:
5✔
523
            ver = context["project"]["version"]
5✔
524
        except KeyError:
5✔
525
            pass
5✔
526
        else:
527
            if isinstance(ver, str):
5✔
528
                if ver in ("0", "0.0.0"):
5✔
529
                    by_version_plugin = True
5✔
530
                elif re.match(r"\d+\.\d+\.\d+", ver):
5✔
531
                    return TOML_FILE
5✔
532
        if not by_version_plugin:
5✔
533
            try:
5✔
534
                version_value = context["tool"]["poetry"]["version"]
5✔
535
            except KeyError:
5✔
536
                if not Project.manage_by_poetry() and (
5✔
537
                    filename := cls.parse_dynamic_version(toml_text, context, work_dir)
538
                ):
539
                    return filename
×
540
            else:
541
                by_version_plugin = version_value in ("0", "0.0.0", "init")
5✔
542
        if by_version_plugin:
5✔
543
            return cls.parse_plugin_version(context, package_name)
5✔
544
        return TOML_FILE
5✔
545

546
    @staticmethod
5✔
547
    def parse_plugin_version(context: dict[str, Any], package_name: str | None) -> str:
5✔
548
        try:
5✔
549
            package_item = context["tool"]["poetry"]["packages"]
5✔
550
        except KeyError:
5✔
551
            try:
5✔
552
                project_name = context["project"]["name"]
5✔
553
            except KeyError:
5✔
554
                packages: list[tuple[str, str]] = []
5✔
555
            else:
556
                packages = [(poetry_module_name(project_name), "")]
×
557
        else:
558
            packages = [
5✔
559
                (j, i.get("from", "")) for i in package_item if (j := i.get("include"))
560
            ]
561
        # In case of managed by `poetry-plugin-version`
562
        cwd = Path.cwd()
5✔
563
        pattern = re.compile(r"__version__\s*=\s*['\"]")
5✔
564
        ds: list[Path] = []
5✔
565
        if package_name is not None:
5✔
566
            packages.insert(0, (package_name, ""))
×
567
        for pack_name, source_dir in packages:
5✔
568
            ds.append(cwd / pack_name)
5✔
569
            ds.append(cwd / "src" / pack_name)
5✔
570
            if source_dir and source_dir != "src":
5✔
571
                ds.append(cwd / source_dir / pack_name)
5✔
572
        module_name = poetry_module_name(cwd.name)
5✔
573
        ds.extend([cwd / module_name, cwd / "src" / module_name, cwd])
5✔
574
        for d in ds:
5✔
575
            init_file = d / "__init__.py"
5✔
576
            if (init_file.exists() and pattern.search(init_file.read_text("utf8"))) or (
5✔
577
                (init_file := init_file.with_name("__version__.py")).exists()
578
                and pattern.search(init_file.read_text("utf8"))
579
            ):
580
                break
5✔
581
        else:
582
            raise ParseError("Version file not found! Where are you now?")
5✔
583
        return os.path.relpath(init_file, cwd)
5✔
584

585
    def get_part(self, s: str) -> str:
5✔
586
        choices: dict[str, str] = {}
5✔
587
        for i, p in enumerate(self.PartChoices, 1):
5✔
588
            v = str(p)
5✔
589
            choices.update({str(i): v, v: v})
5✔
590
        try:
5✔
591
            return choices[s]
5✔
592
        except KeyError as e:
5✔
593
            echo(f"Invalid part: {s!r}")
5✔
594
            raise Exit(1) from e
5✔
595

596
    def gen(self) -> str:
5✔
597
        should_sync, _version = get_current_version(check_version=True)
5✔
598
        filename = self.filename
5✔
599
        echo(f"Current version(@{filename}): {_version}")
5✔
600
        if self.part:
5✔
601
            part = self.get_part(self.part)
5✔
602
        else:
603
            part = "patch"
5✔
604
            if a := input("Which one?").strip():
5✔
605
                part = self.get_part(a)
5✔
606
        self.part = part
5✔
607
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5✔
608
        filename_arg = _quote_shell_arg(filename)
5✔
609
        cmd = (
5✔
610
            f'bumpversion {parse} --current-version="{_version}" {part} {filename_arg}'
611
        )
612
        if self.commit:
5✔
613
            if part != "patch":
5✔
614
                cmd += " --tag"
5✔
615
            cmd += " --commit"
5✔
616
            if self._emoji or (self._emoji is None and self.should_add_emoji()):
5✔
617
                cmd += " --message-emoji=1"
5✔
618
            if not load_bool("DONT_GIT_PUSH"):
5✔
619
                cmd += " && git push && git push --tags && git log -1"
5✔
620
        else:
621
            cmd += " --allow-dirty"
5✔
622
        if (
5✔
623
            should_sync
624
            and not self._no_sync
625
            and (sync := Project.get_sync_command(only_me=True))
626
        ):
627
            cmd = f"{sync} && " + cmd
5✔
628
        return cmd
5✔
629

630
    def run(self) -> None:
5✔
631
        super().run()
5✔
632
        if not self.commit and not self.dry:
5✔
633
            new_version = get_current_version(True)
5✔
634
            echo(new_version)
5✔
635
            if self.part != "patch":
5✔
636
                echo("You may want to pin tag by `fast tag`")
5✔
637

638

639
def _echo_version(version_file: Any, value: str) -> None:
5✔
640
    styled = typer.style(value, bold=True)
5✔
641
    echo(f"Version value in {version_file}: " + styled)
5✔
642

643

644
@cli.command()
5✔
645
def version() -> None:
5✔
646
    """Show the version of this tool"""
647
    echo("Fast Dev Cli Version: " + typer.style(__version__, fg=typer.colors.BLUE))
5✔
648
    with contextlib.suppress(FileNotFoundError, KeyError):
5✔
649
        try:
5✔
650
            toml_text = Project.load_toml_text()
5✔
651
        except EnvError:
×
652
            if (res := _get_frontend_version()) is not None:
×
653
                _echo_version(*res)
×
654
                return
×
655
            raise
×
656
        doc = tomllib.loads(toml_text)
5✔
657
        if value := doc.get("project", {}).get("version", ""):
5✔
658
            styled = typer.style(value, bold=True, fg=typer.colors.CYAN)
×
659
            if project_name := doc["project"].get("name", ""):
×
660
                echo(f"{project_name} version: " + styled)
×
661
            else:
662
                echo(f"Got Version from {TOML_FILE}: " + styled)
×
663
            return
×
664
        version_file = doc["tool"]["pdm"]["version"]["path"]
5✔
665
        text = Project.get_work_dir().joinpath(version_file).read_text(encoding="utf-8")
5✔
666
        varname = "__version__"
5✔
667
        for line in text.splitlines():
5✔
668
            if line.strip().startswith(varname):
5✔
669
                value = line.split("=", 1)[-1].strip().strip('"').strip("'")
5✔
670
                _echo_version(version_file, value)
5✔
671
                break
5✔
672

673

674
@cli.command(name="bump")
5✔
675
def bump_version(
5✔
676
    part: BumpUp.PartChoices,
677
    commit: bool = Option(
678
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
679
    ),
680
    emoji: bool | None = Option(
681
        None, "--emoji", help="Whether add emoji prefix to commit message"
682
    ),
683
    no_sync: bool = Option(
684
        False, "--no-sync", help="Do not run sync command to update version"
685
    ),
686
    dry: bool = DryOption,
687
) -> None:
688
    """Bump up version string in pyproject.toml"""
689
    if emoji is not None:
5✔
690
        emoji = _ensure_bool(emoji)
5✔
691
    return BumpUp(
5✔
692
        _ensure_bool(commit),
693
        getattr(part, "value", part),
694
        no_sync=_ensure_bool(no_sync),
695
        emoji=emoji,
696
        dry=dry,
697
    ).run()
698

699

700
def bump() -> None:
5✔
701
    part, commit = "", False
5✔
702
    if args := sys.argv[2:]:
5✔
703
        if "-c" in args or "--commit" in args:
5✔
704
            commit = True
5✔
705
        for a in args:
5✔
706
            if not a.startswith("-"):
5✔
707
                part = a
5✔
708
                break
5✔
709
    return BumpUp(commit, part, no_sync="--no-sync" in args, dry="--dry" in args).run()
5✔
710

711

712
class Project:
5✔
713
    path_depth = 5
5✔
714
    _tool: ToolName | None = None
5✔
715

716
    @staticmethod
5✔
717
    def is_poetry_v2(text: str) -> bool:
5✔
718
        return 'build-backend = "poetry' in text
5✔
719

720
    @staticmethod
5✔
721
    def get_poetry_version(command: str = "poetry") -> str:
5✔
722
        pattern = r"(\d+\.\d+\.\d+)"
5✔
723
        text = capture_cmd_output(f"{command} --version")
5✔
724
        for expr in (
5✔
725
            rf"Poetry \(version {pattern}\)",
726
            rf"Poetry.*version.*{pattern}.*\)",
727
            rf"{pattern}",
728
        ):
729
            if m := re.search(expr, text):
5✔
730
                return m.group(1)
5✔
731
        return ""
×
732

733
    @staticmethod
5✔
734
    def work_dir(
5✔
735
        name: str, parent: Path, depth: int, be_file: bool = False
736
    ) -> Path | None:
737
        for _ in range(depth):
5✔
738
            if (f := parent.joinpath(name)).exists():
5✔
739
                if be_file:
5✔
740
                    return f
5✔
741
                return parent
5✔
742
            parent = parent.parent
5✔
743
        return None
5✔
744

745
    @classmethod
5✔
746
    def get_work_dir(
5✔
747
        cls: type[Self],
748
        name: str = TOML_FILE,
749
        cwd: Path | None = None,
750
        allow_cwd: bool = False,
751
        be_file: bool = False,
752
    ) -> Path:
753
        cwd = cwd or Path.cwd()
5✔
754
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5✔
755
            return d
5✔
756
        if allow_cwd:
5✔
757
            return cls.get_root_dir(cwd)
5✔
758
        raise EnvError(f"{name} not found! Make sure this is a python project.")
5✔
759

760
    @classmethod
5✔
761
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
5✔
762
        toml_file = cls.get_work_dir(name, be_file=True)
5✔
763
        return toml_file.read_text("utf8")
5✔
764

765
    @classmethod
5✔
766
    def manage_by_poetry(cls: type[Self], cache: bool = False) -> bool:
5✔
767
        return cls.get_manage_tool(cache=cache) == "poetry"
5✔
768

769
    @classmethod
5✔
770
    def get_manage_tool(cls: type[Self], cache: bool = False) -> ToolName | None:
5✔
771
        if cache and cls._tool:
5✔
772
            return cls._tool
5✔
773
        try:
5✔
774
            text = cls.load_toml_text()
5✔
775
        except EnvError:
5✔
776
            return None
5✔
777
        backend = ""
5✔
778
        skip_uv = load_bool("FASTDEVCLI_SKIP_UV")
5✔
779
        with contextlib.suppress(KeyError, tomllib.TOMLDecodeError):
5✔
780
            doc = tomllib.loads(text)
5✔
781
            backend = doc["build-system"]["build-backend"]
5✔
782
            if skip_uv:
5✔
783
                for t in ("pdm", "poetry"):
×
784
                    if t in backend:
×
785
                        cls._tool = t
×
786
                        return cls._tool
×
787
        return cls._get_manage_tool(text, backend, skip_uv)
5✔
788

789
    @classmethod
5✔
790
    def _get_manage_tool(
5✔
791
        cls: type[Self], text: str, backend: str, skip_uv: bool
792
    ) -> ToolName | None:
793
        work_dir: Path | None = None
5✔
794
        uv_lock_exists: bool | None = None
5✔
795
        if skip_uv:
5✔
796
            for name in ("pdm", "poetry"):
×
797
                if f"[tool.{name}]" in text:
×
798
                    cls._tool = name
×
799
                    return cls._tool
×
800
            work_dir = cls.get_work_dir(allow_cwd=True)
×
801
            for name in ("pdm", "poetry"):
×
802
                if Path(work_dir, f"{name}.lock").exists():
×
803
                    cls._tool = cast(ToolName, name)
×
804
                    return cls._tool
×
805
            if uv_lock_exists := Path(work_dir, "uv.lock").exists():
×
806
                # Use pdm when uv is not available for uv managed project
807
                cls._tool = "pdm"
×
808
                return cls._tool
×
809
            return None
×
810
        if work_dir is None:
5✔
811
            work_dir = cls.get_work_dir(allow_cwd=True)
5✔
812
        if uv_lock_exists is None:
5✔
813
            uv_lock_exists = Path(work_dir, "uv.lock").exists()
5✔
814
        if uv_lock_exists:
5✔
815
            cls._tool = "uv"
5✔
816
            return cls._tool
5✔
817
        return cls._parse_manage_tool(work_dir, text, backend)
5✔
818

819
    @classmethod
5✔
820
    def _parse_manage_tool(
5✔
821
        cls: type[Self], work_dir: Path, text: str, backend: str
822
    ) -> ToolName | None:
823
        pdm_lock_exists = Path(work_dir, "pdm.lock").exists()
5✔
824
        poetry_lock_exists = Path(work_dir, "poetry.lock").exists()
5✔
825
        match pdm_lock_exists + poetry_lock_exists:
5✔
826
            case 1:
5✔
827
                cls._tool = "pdm" if pdm_lock_exists else "poetry"
×
828
                return cls._tool
×
829
            case _ as x:
5✔
830
                if backend:
5✔
831
                    for t in ("pdm", "poetry"):
5✔
832
                        if t in backend:
5✔
833
                            cls._tool = t
5✔
834
                            return cls._tool
5✔
835
                for name in ("pdm", "poetry"):
5✔
836
                    if f"[tool.{name}]" in text:
5✔
837
                        cls._tool = name
5✔
838
                        return cls._tool
5✔
839
                if x == 2:
5✔
840
                    cls._tool = (
×
841
                        "poetry" if load_bool("FASTDEVCLI_PREFER_POETRY") else "pdm"
842
                    )
843
                    return cls._tool
1✔
844
        if "[tool.uv]" in text or load_bool("FASTDEVCLI_PREFER_uv"):
5✔
845
            cls._tool = "uv"
5✔
846
            return cls._tool
5✔
847
        # Poetry 2.0 default to not include the '[tool.poetry]' section
848
        if cls.is_poetry_v2(text):
5✔
849
            cls._tool = "poetry"
×
850
            return cls._tool
×
851
        return None
5✔
852

853
    @staticmethod
5✔
854
    def python_exec_dir() -> Path:
5✔
855
        return Path(sys.executable).parent
5✔
856

857
    @classmethod
5✔
858
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5✔
859
        root = cwd or Path.cwd()
5✔
860
        venv_parent = cls.python_exec_dir().parent.parent
5✔
861
        if root.is_relative_to(venv_parent):
5✔
862
            root = venv_parent
5✔
863
        return root
5✔
864

865
    @classmethod
5✔
866
    def is_pdm_project(cls, strict: bool = True, cache: bool = False) -> bool:
5✔
867
        if cls.get_manage_tool(cache=cache) != "pdm":
5✔
868
            return False
5✔
869
        if strict:
×
870
            lock_file = cls.get_work_dir() / "pdm.lock"
×
871
            return lock_file.exists()
×
872
        return True
×
873

874
    @classmethod
5✔
875
    def get_sync_command(
5✔
876
        cls, prod: bool = True, doc: dict[str, Any] | None = None, only_me: bool = False
877
    ) -> str:
878
        pdm_i = "pdm install --frozen" + " --prod" * prod
5✔
879
        if cls.is_pdm_project():
5✔
880
            return pdm_i
×
881
        elif cls.manage_by_poetry(cache=True):
5✔
882
            cmd = "poetry install"
5✔
883
            if prod:
5✔
884
                if doc is None:
5✔
885
                    doc = tomllib.loads(cls.load_toml_text())
5✔
886
                if doc.get("project", {}).get("dependencies") or any(
5✔
887
                    i != "python"
888
                    for i in doc.get("tool", {})
889
                    .get("poetry", {})
890
                    .get("dependencies", [])
891
                ):
892
                    cmd += " --only=main"
×
893
            return cmd
5✔
894
        elif cls.get_manage_tool(cache=True) == "uv":
×
895
            install_me = "uv pip install -e ."
×
896
            if doc is None:
×
897
                doc = tomllib.loads(cls.load_toml_text())
×
898
            is_distribution = (
×
899
                doc.get("tool", {}).get("pdm", {}).get("distribution") is not False
900
            )
901
            if only_me:
×
902
                return install_me if is_distribution else pdm_i
×
903
            cmd = "uv sync --inexact" + " --no-dev" * prod
×
904
            if is_distribution:
×
905
                cmd += f" && {install_me}"
×
906
        return ""
×
907

908
    @classmethod
5✔
909
    def sync_dependencies(cls, prod: bool = True) -> None:
5✔
910
        if cmd := cls.get_sync_command():
×
911
            run_and_echo(cmd)
×
912

913

914
class UpgradeDependencies(Project, DryRun):
5✔
915
    def __init__(
5✔
916
        self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
917
    ) -> None:
918
        super().__init__(_exit, dry)
5✔
919
        self._tool = tool
5✔
920

921
    class DevFlag(StrEnum):
5✔
922
        new = "[tool.poetry.group.dev.dependencies]"
5✔
923
        old = "[tool.poetry.dev-dependencies]"
5✔
924

925
    @staticmethod
5✔
926
    def parse_value(version_info: str, key: str) -> str:
5✔
927
        """Pick out the value for key in version info.
928

929
        Example::
930
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
931
            >>> UpgradeDependencies.parse_value(s, 'extras')
932
            'all'
933
            >>> UpgradeDependencies.parse_value(s, 'optional')
934
            'true'
935
            >>> UpgradeDependencies.parse_value(s, 'version')
936
            '^0.9.0'
937
        """
938
        sep = key + " = "
5✔
939
        rest = version_info.split(sep, 1)[-1].strip(" =")
5✔
940
        if rest.startswith("["):
5✔
941
            rest = rest[1:].split("]")[0]
5✔
942
        elif rest.startswith('"'):
5✔
943
            rest = rest[1:].split('"')[0]
5✔
944
        else:
945
            rest = rest.split(",")[0].split("}")[0]
5✔
946
        return rest.strip().replace('"', "")
5✔
947

948
    @staticmethod
5✔
949
    def no_need_upgrade(version_info: str, line: str) -> bool:
5✔
950
        if (v := version_info.replace(" ", "")).startswith("{url="):
5✔
951
            echo(f"No need to upgrade for: {line}")
5✔
952
            return True
5✔
953
        if (f := "version=") in v:
5✔
954
            v = v.split(f)[1].strip('"').split('"')[0]
5✔
955
        if v == "*":
5✔
956
            echo(f"Skip wildcard line: {line}")
5✔
957
            return True
5✔
958
        elif v == "[":
5✔
959
            echo(f"Skip complex dependence: {line}")
5✔
960
            return True
5✔
961
        elif v.startswith((">", "<")) or v[0].isdigit():
5✔
962
            echo(f"Ignore bigger/smaller/equal: {line}")
5✔
963
            return True
5✔
964
        return False
5✔
965

966
    @classmethod
5✔
967
    def build_args(
5✔
968
        cls: type[Self], package_lines: list[str]
969
    ) -> tuple[list[str], dict[str, list[str]]]:
970
        args: list[str] = []  # ['typer[all]', 'fastapi']
5✔
971
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
5✔
972
        for no, line in enumerate(package_lines, 1):
5✔
973
            if (
5✔
974
                not (m := line.strip())
975
                or m.startswith("#")
976
                or m == "]"
977
                or (m.startswith("{") and m.strip(",").endswith("}"))
978
            ):
979
                continue
5✔
980
            try:
5✔
981
                package, version_info = m.split("=", 1)
5✔
982
            except ValueError as e:
5✔
983
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
5✔
984
            if (package := package.strip()).lower() == "python":
5✔
985
                continue
5✔
986
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
5✔
987
                continue
5✔
988
            if (extras_tip := "extras") in version_info:
5✔
989
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
5✔
990
            item = f'"{package}@latest"'
5✔
991
            key = None
5✔
992
            if (pf := "platform") in version_info:
5✔
993
                platform = cls.parse_value(version_info, pf)
5✔
994
                key = f"--{pf}={platform}"
5✔
995
            if (sc := "source") in version_info:
5✔
996
                source = cls.parse_value(version_info, sc)
5✔
997
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
5✔
998
            if "optional = true" in version_info:
5✔
999
                key = ("" if key is None else (key + " ")) + "--optional"
5✔
1000
            if key is not None:
5✔
1001
                specials[key] = specials.get(key, []) + [item]
5✔
1002
            else:
1003
                args.append(item)
5✔
1004
        return args, specials
5✔
1005

1006
    @classmethod
5✔
1007
    def should_with_dev(cls: type[Self]) -> bool:
5✔
1008
        text = cls.load_toml_text()
5✔
1009
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
1010

1011
    @staticmethod
5✔
1012
    def parse_item(toml_str: str) -> list[str]:
5✔
1013
        lines: list[str] = []
5✔
1014
        for line in toml_str.splitlines():
5✔
1015
            if (line := line.strip()).startswith("["):
5✔
1016
                if lines:
5✔
1017
                    break
5✔
1018
            elif line:
5✔
1019
                lines.append(line)
5✔
1020
        return lines
5✔
1021

1022
    @classmethod
5✔
1023
    def get_args(
5✔
1024
        cls: type[Self], toml_text: str | None = None
1025
    ) -> tuple[list[str], list[str], list[list[str]], str]:
1026
        if toml_text is None:
5✔
1027
            toml_text = cls.load_toml_text()
5✔
1028
        main_title = "[tool.poetry.dependencies]"
5✔
1029
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
5✔
1030
            toml_text
1031
        ):
1032
            raise EnvError(
5✔
1033
                f"{main_title} not found! Make sure this is a poetry project."
1034
            )
1035
        text = toml_text.split(main_title)[-1]
5✔
1036
        dev_flag = "--group dev"
5✔
1037
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
5✔
1038
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
5✔
1039
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
5✔
1040
            dev_flag = "--dev"
5✔
1041
        others: list[list[str]] = []
5✔
1042
        try:
5✔
1043
            main_toml, dev_toml = text.split(dev_title)
5✔
1044
        except ValueError:
5✔
1045
            dev_toml = ""
5✔
1046
            main_toml = text
5✔
1047
        mains = [] if no_main_deps else cls.parse_item(main_toml)
5✔
1048
        devs = cls.parse_item(dev_toml)
5✔
1049
        prod_packs, specials = cls.build_args(mains)
5✔
1050
        if specials:
5✔
1051
            others.extend([[k] + v for k, v in specials.items()])
5✔
1052
        dev_packs, specials = cls.build_args(devs)
5✔
1053
        if specials:
5✔
1054
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
5✔
1055
        return prod_packs, dev_packs, others, dev_flag
5✔
1056

1057
    @classmethod
5✔
1058
    def gen_cmd(cls: type[Self]) -> str:
5✔
1059
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
1060
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
1061

1062
    @staticmethod
5✔
1063
    def to_cmd(
5✔
1064
        main_args: list[str],
1065
        dev_args: list[str],
1066
        others: list[list[str]],
1067
        dev_flags: str,
1068
    ) -> str:
1069
        command = "poetry add "
5✔
1070
        _upgrade = ""
5✔
1071
        if main_args:
5✔
1072
            _upgrade = command + " ".join(main_args)
5✔
1073
        if dev_args:
5✔
1074
            if _upgrade:
5✔
1075
                _upgrade += " && "
5✔
1076
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5✔
1077
        for single in others:
5✔
1078
            _upgrade += f" && poetry add {' '.join(single)}"
5✔
1079
        return _upgrade
5✔
1080

1081
    def gen(self) -> str:
5✔
1082
        if self._tool == "uv":
5✔
1083
            up = "uv lock --upgrade --verbose"
5✔
1084
            deps = "uv sync --inexact --frozen --all-groups --all-extras"
5✔
1085
            return f"{up} && {deps}"
5✔
1086
        elif self._tool == "pdm":
5✔
1087
            return "pdm update --verbose && pdm install -G :all --frozen"
5✔
1088
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
1089

1090

1091
@cli.command()
5✔
1092
def upgrade(
5✔
1093
    tool: str = ToolOption,
1094
    dry: bool = DryOption,
1095
) -> None:
1096
    """Upgrade dependencies in pyproject.toml to latest versions"""
1097
    if not (tool := _ensure_str(tool) or "") or tool == ToolOption.default:
5✔
1098
        tool = Project.get_manage_tool() or "uv"
5✔
1099
    if tool in get_args(ToolName):
5✔
1100
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
5✔
1101
    else:
1102
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
5✔
1103
        raise typer.Exit(1)
5✔
1104

1105

1106
class GitTag(DryRun):
5✔
1107
    def __init__(self, message: str, dry: bool, no_sync: bool = False) -> None:
5✔
1108
        self.message = message
5✔
1109
        self._no_sync = no_sync
5✔
1110
        super().__init__(dry=dry)
5✔
1111

1112
    @staticmethod
5✔
1113
    def has_v_prefix() -> bool:
5✔
1114
        return "v" in capture_cmd_output("git tag")
5✔
1115

1116
    def should_push(self) -> bool:
5✔
1117
        return "git push" in self.git_status
5✔
1118

1119
    def gen(self) -> str:
5✔
1120
        should_sync, _version = get_current_version(verbose=False, check_version=True)
5✔
1121
        if self.has_v_prefix():
5✔
1122
            # Add `v` at prefix to compare with bumpversion tool
1123
            _version = "v" + _version
5✔
1124
        cmd = (
5✔
1125
            f"git tag -a {_quote_shell_arg(_version)} "
1126
            f"-m {_quote_shell_arg(self.message)} && git push --tags"
1127
        )
1128
        if self.should_push():
5✔
1129
            cmd += " && git push"
5✔
1130
        if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
5✔
1131
            cmd = f"{sync} && " + cmd
×
1132
        return cmd
5✔
1133

1134
    @cached_property
5✔
1135
    def git_status(self) -> str:
5✔
1136
        return capture_cmd_output("git status")
5✔
1137

1138
    def mark_tag(self) -> bool:
5✔
1139
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5✔
1140
            "无文件要提交,干净的工作区" not in self.git_status
1141
        ):
1142
            run_and_echo("git status")
5✔
1143
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5✔
1144
            return False
5✔
1145
        return bool(super().run())
5✔
1146

1147
    def run(self) -> None:
5✔
1148
        if self.mark_tag() and not self.dry:
5✔
1149
            echo("You may want to publish package:\n pdm publish")
5✔
1150

1151

1152
@cli.command()
5✔
1153
def tag(
5✔
1154
    message: str = Option("", "-m", "--message"),
1155
    no_sync: bool = Option(
1156
        False, "--no-sync", help="Do not run sync command to update version"
1157
    ),
1158
    dry: bool = DryOption,
1159
) -> None:
1160
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
1161
    GitTag(message, dry=dry, no_sync=_ensure_bool(no_sync)).run()
5✔
1162

1163

1164
class LintCode(DryRun):
5✔
1165
    def __init__(
5✔
1166
        self,
1167
        args: list[str] | str | None,
1168
        check_only: bool = False,
1169
        _exit: bool = False,
1170
        dry: bool = False,
1171
        bandit: bool = False,
1172
        skip_mypy: bool = False,
1173
        dmypy: bool = False,
1174
        tool: str = ToolOption.default,
1175
        prefix: bool = False,
1176
        up: bool = False,
1177
        sim: bool = True,
1178
        strict: bool = False,
1179
        ty: bool = False,
1180
        fix: bool = True,
1181
    ) -> None:
1182
        self.args = args
5✔
1183
        self.check_only = check_only
5✔
1184
        self._bandit = bandit
5✔
1185
        self._skip_mypy = skip_mypy
5✔
1186
        self._use_dmypy = dmypy
5✔
1187
        self._tool = tool
5✔
1188
        self._prefix = prefix
5✔
1189
        self._up = up
5✔
1190
        self._sim = sim
5✔
1191
        self._strict = strict
5✔
1192
        self._ty = _ensure_bool(ty)
5✔
1193
        self._fix = _ensure_bool(fix)
5✔
1194
        super().__init__(_exit, dry)
5✔
1195

1196
    @staticmethod
5✔
1197
    def check_lint_tool_installed() -> bool:
5✔
1198
        try:
5✔
1199
            return check_call("ruff --version")
5✔
1200
        except FileNotFoundError:
×
1201
            # Windows may raise FileNotFoundError when ruff not installed
1202
            return False
×
1203

1204
    @staticmethod
5✔
1205
    def missing_mypy_exec() -> bool:
5✔
1206
        return shutil.which("mypy") is None
5✔
1207

1208
    @staticmethod
5✔
1209
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
5✔
1210
        return (
5✔
1211
            paths == "."
1212
            and any(t.startswith("mypy") for t in tools)
1213
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
1214
        )
1215

1216
    @staticmethod
5✔
1217
    def get_package_name() -> str:
5✔
1218
        root = Project.get_work_dir(allow_cwd=True)
5✔
1219
        module_name = root.name.replace("-", "_").replace(" ", "_")
5✔
1220
        package_maybe = (module_name, "src")
5✔
1221
        for name in package_maybe:
5✔
1222
            if root.joinpath(name).is_dir():
5✔
1223
                return name
5✔
1224
        return "."
5✔
1225

1226
    @classmethod
5✔
1227
    def to_cmd(
5✔
1228
        cls: type[Self],
1229
        paths: str | list[str] = ".",
1230
        check_only: bool = False,
1231
        bandit: bool = False,
1232
        skip_mypy: bool = False,
1233
        use_dmypy: bool = False,
1234
        tool: str = ToolOption.default,
1235
        with_prefix: bool = False,
1236
        ruff_check_up: bool = False,
1237
        ruff_check_sim: bool = True,
1238
        mypy_strict: bool = False,
1239
        prefer_ty: bool = False,
1240
        ruff_check_fix: bool = True,
1241
    ) -> str:
1242
        path_args = shlex.split(paths) if isinstance(paths, str) else paths
5✔
1243
        if not path_args:
5✔
1244
            path_args = ["."]
×
1245
        quoted_paths = _join_shell_args(path_args)
5✔
1246
        if path_args != ["."] and all(i.endswith(".html") for i in path_args):
5✔
1247
            return f"prettier -w {quoted_paths}"
5✔
1248
        ruff_rules = ["I", "B"]
5✔
1249
        if ruff_check_sim and not load_bool("FASTDEVCLI_NO_SIM"):
5✔
1250
            ruff_rules.append("SIM")
5✔
1251
        if ruff_check_up or load_bool("FASTDEVCLI_UP"):
5✔
1252
            ruff_rules.append("UP")
×
1253
        ruff_check = "ruff check --extend-select=" + ",".join(ruff_rules)
5✔
1254
        if (
5✔
1255
            ruff_check_fix
1256
            and not check_only
1257
            and (not load_bool("NO_FIX") and not load_bool("FASTDEVCLI_NO_FIX"))
1258
        ):
1259
            ruff_check += " --fix"
5✔
1260
        tools = ["ruff format", ruff_check, "mypy"]
5✔
1261
        if check_only:
5✔
1262
            tools[0] += " --check"
5✔
1263
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
5✔
1264
            # Sometimes mypy is too slow
1265
            tools = tools[:-1]
5✔
1266
        else:
1267
            if prefer_ty or load_bool("FASTDEVCLI_TY"):
5✔
1268
                tools[-1] = "ty check"
×
1269
            else:
1270
                if load_bool("IGNORE_MISSING_IMPORTS"):
5✔
1271
                    tools[-1] += " --ignore-missing-imports"
5✔
1272
                if mypy_strict or load_bool("FASTDEVCLI_STRICT"):
5✔
1273
                    tools[-1] += " --strict"
×
1274
        ruff_exists = cls.check_lint_tool_installed()
5✔
1275
        prefix = ""
5✔
1276
        should_run_by_tool = with_prefix
5✔
1277
        requires_mypy = any(tool.startswith("mypy") for tool in tools)
5✔
1278
        global_mypy = False
5✔
1279
        if requires_mypy:
5✔
1280
            # TODO: move this long logic to a single function
1281
            local_bin = Path.home().joinpath(".local/bin")
5✔
1282
            if local_bin.joinpath("mypy").exists():
5✔
1283
                global_mypy = True
×
1284
                mypy_opt = "--python-executable=.venv/bin/python"
×
1285
                for i, t in enumerate(tools):
×
1286
                    if t.startswith("mypy"):
×
1287
                        if mypy_opt not in t:
×
1288
                            tools[i] = t + " " + mypy_opt
×
1289
                        break
×
1290
            if not should_run_by_tool:
5✔
1291
                if is_venv() and Path(sys.argv[0]).parent != local_bin:
5✔
1292
                    # Virtual environment activated and fast-dev-cli is installed in it
1293
                    if not ruff_exists:
5✔
1294
                        should_run_by_tool = True
5✔
1295
                        command = "pipx install ruff"
5✔
1296
                        if shutil.which("pipx") is None:
5✔
1297
                            ensure_pipx = (
×
1298
                                "pip install --user pipx\n  pipx ensurepath\n  "
1299
                            )
1300
                            command = ensure_pipx + command
×
1301
                        elif prefer_uv_tool():
5✔
1302
                            command = "uv tool install ruff"
5✔
1303
                        yellow_warn(
5✔
1304
                            "You may need to run the following command"
1305
                            f" to install ruff:\n\n  {command}\n"
1306
                        )
1307
                    elif global_mypy:
5✔
1308
                        should_run_by_tool = True
×
1309
                    elif cls.missing_mypy_exec():
5✔
1310
                        should_run_by_tool = True
5✔
1311
                        if check_call('python -c "import fast_dev_cli"'):
5✔
1312
                            command = "python -m pip install -U mypy"
5✔
1313
                            yellow_warn(
5✔
1314
                                "You may need to run the following command"
1315
                                f" to install lint tools:\n\n  {command}\n"
1316
                            )
1317
                elif tool == ToolOption.default:
×
1318
                    root = Project.get_work_dir(allow_cwd=True)
×
1319
                    if py := shutil.which("python"):
×
1320
                        try:
×
1321
                            Path(py).relative_to(root)
×
1322
                        except ValueError:
×
1323
                            # Virtual environment not activated
1324
                            should_run_by_tool = True
×
1325
                else:
1326
                    should_run_by_tool = True
×
1327
        if should_run_by_tool and tool:
5✔
1328
            if tool == ToolOption.default:
5✔
1329
                tool = Project.get_manage_tool() or ""
5✔
1330
            if tool:
5✔
1331
                prefix = tool + " run "
5✔
1332
                if tool == "uv":
5✔
1333
                    if is_windows():
5✔
1334
                        prefix += "--no-sync "
×
1335
                    elif Path(bin_dir := ".venv/bin/").exists():
5✔
1336
                        prefix = bin_dir
5✔
1337
        if cls.prefer_dmypy(quoted_paths, tools, use_dmypy=use_dmypy):
5✔
1338
            tools[-1] = "dmypy run"
5✔
1339
        cmd = " && ".join(
5✔
1340
            (
1341
                tool
1342
                # `ruff <command>` get the same result with `pdm run ruff <command>`.
1343
                # Other tools should run inside the selected environment.
1344
                if (
1345
                    ruff_exists
1346
                    and tool.startswith("ruff")
1347
                    or (global_mypy and tool.startswith("mypy"))
1348
                )
1349
                else prefix + tool
1350
            )
1351
            + f" {quoted_paths}"
1352
            for tool in tools
1353
        )
1354
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
5✔
1355
            command = prefix + "bandit"
5✔
1356
            if Path("pyproject.toml").exists():
5✔
1357
                toml_text = Project.load_toml_text()
5✔
1358
                if "[tool.bandit" in toml_text:
5✔
1359
                    command += " -c pyproject.toml"
5✔
1360
            if quoted_paths == "." and " -c " not in command:
5✔
1361
                quoted_paths = _quote_shell_arg(cls.get_package_name())
5✔
1362
            command += f" -r {quoted_paths}"
5✔
1363
            cmd += " && " + command
5✔
1364
        return cmd
5✔
1365

1366
    def gen(self) -> str:
5✔
1367
        paths = ["."]
5✔
1368
        if args := self.args:
5✔
1369
            ps = shlex.split(args) if isinstance(args, str) else [str(i) for i in args]
5✔
1370
            if len(ps) == 1:
5✔
1371
                path = ps[0]
5✔
1372
                if (
5✔
1373
                    path != "."
1374
                    # `Path("a.").suffix` got "." in py3.14 and got "" with py<3.14
1375
                    and (p := Path(path)).suffix in ("", ".")
1376
                    and not p.exists()
1377
                ):
1378
                    # e.g.:
1379
                    # stem -> stem.py
1380
                    # me. -> me.py
1381
                    if path.endswith("."):
×
1382
                        p = p.with_name(path[:-1])
×
1383
                    for suffix in (".py", ".html"):
×
1384
                        p = p.with_suffix(suffix)
×
1385
                        if p.exists():
×
1386
                            ps[0] = p.name
×
1387
                            break
×
1388
            paths = ps
5✔
1389
        return self.to_cmd(
5✔
1390
            paths,
1391
            self.check_only,
1392
            self._bandit,
1393
            self._skip_mypy,
1394
            self._use_dmypy,
1395
            tool=self._tool,
1396
            with_prefix=self._prefix,
1397
            ruff_check_up=self._up,
1398
            ruff_check_sim=self._sim,
1399
            mypy_strict=self._strict,
1400
            prefer_ty=self._ty,
1401
            ruff_check_fix=self._fix,
1402
        )
1403

1404

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

1408

1409
def lint(
5✔
1410
    files: list[str] | str | None = None,
1411
    dry: bool = False,
1412
    bandit: bool = False,
1413
    skip_mypy: bool = False,
1414
    dmypy: bool = False,
1415
    tool: str = ToolOption.default,
1416
    prefix: bool = False,
1417
    up: bool = False,
1418
    sim: bool = True,
1419
    strict: bool = False,
1420
    ty: bool = False,
1421
    fix: bool = True,
1422
) -> None:
1423
    if files is None:
5✔
1424
        files = parse_files(sys.argv[1:])
5✔
1425
    if files and files[0] == "lint":
5✔
1426
        files = files[1:]
5✔
1427
    LintCode(
5✔
1428
        files,
1429
        dry=dry,
1430
        skip_mypy=skip_mypy,
1431
        bandit=bandit,
1432
        dmypy=dmypy,
1433
        tool=tool,
1434
        prefix=prefix,
1435
        up=up,
1436
        sim=sim,
1437
        strict=strict,
1438
        ty=ty,
1439
        fix=fix,
1440
    ).run()
1441

1442

1443
def check(
5✔
1444
    files: list[str] | str | None = None,
1445
    dry: bool = False,
1446
    bandit: bool = False,
1447
    skip_mypy: bool = False,
1448
    dmypy: bool = False,
1449
    tool: str = ToolOption.default,
1450
    up: bool = False,
1451
    sim: bool = True,
1452
    strict: bool = False,
1453
    ty: bool = False,
1454
) -> None:
1455
    LintCode(
5✔
1456
        files,
1457
        check_only=True,
1458
        _exit=True,
1459
        dry=dry,
1460
        bandit=bandit,
1461
        skip_mypy=skip_mypy,
1462
        dmypy=dmypy,
1463
        tool=tool,
1464
        up=up,
1465
        sim=sim,
1466
        strict=strict,
1467
        ty=ty,
1468
    ).run()
1469

1470

1471
def _should_bandit() -> bool:
5✔
1472
    if v := os.getenv("FASTDEVCLI_BANDIT"):
×
1473
        return _convert_bool(v) or True
×
1474
    toml_text = Project.load_toml_text()
×
1475
    lines = toml_text.splitlines()
×
1476
    return any(i.startswith("[tool.bandit") for i in lines)
×
1477

1478

1479
@cli.command(name="lint")
5✔
1480
def make_style(
5✔
1481
    files: list[str] | None = typer.Argument(default=None),  # noqa:B008
1482
    check_only: bool = Option(False, "--check-only", "-c"),
1483
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1484
    prefix: bool = Option(
1485
        False,
1486
        "--prefix",
1487
        help="Run lint command with tool prefix, e.g.: pdm run ruff ...",
1488
    ),
1489
    skip_mypy: bool = Option(False, "--skip-mypy"),
1490
    use_dmypy: bool = Option(
1491
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
1492
    ),
1493
    tool: str = ToolOption,
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
    fix: bool | None = Option(None, help="Whether ruff check with --fix"),
1500
    auto_bandit: bool | None = Option(
1501
        None, help="Whether to run bandit if `[tool.bandit]` in pyproject.toml"
1502
    ),
1503
) -> None:
1504
    """Run: ruff check/format to reformat code and then mypy to check"""
1505
    if getattr(files, "default", files) is None:
5✔
1506
        files = ["."]
5✔
1507
    elif isinstance(files, str):
5✔
1508
        files = [files]
5✔
1509
    skip = _ensure_bool(skip_mypy)
5✔
1510
    dmypy = _ensure_bool(use_dmypy)
5✔
1511
    bandit = _ensure_bool(bandit) or (
5✔
1512
        auto_bandit is not None and _ensure_bool(auto_bandit) and _should_bandit()
1513
    )
1514
    tool = _ensure_str(tool) or ""
5✔
1515
    up = _ensure_bool(up)
5✔
1516
    sim = _ensure_bool(sim)
5✔
1517
    strict = _ensure_bool(strict)
5✔
1518
    kwargs = {"dry": dry, "skip_mypy": skip, "dmypy": dmypy, "bandit": bandit}
5✔
1519
    if _ensure_bool(check_only):
5✔
1520
        run = check
5✔
1521
    else:
1522
        prefix = _ensure_bool(prefix)
5✔
1523
        if fix is None or not isinstance(fix, bool):
5✔
1524
            fix = load_bool("FASTDEVCLI_FIX", True)
5✔
1525
        run = functools.partial(lint, prefix=prefix, fix=fix)
5✔
1526
    run(files, tool=tool, up=up, sim=sim, strict=strict, ty=ty, **kwargs)
5✔
1527

1528

1529
@cli.command(name="check")
5✔
1530
def only_check(
5✔
1531
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1532
    skip_mypy: bool = Option(False, "--skip-mypy"),
1533
    dry: bool = DryOption,
1534
    up: bool = Option(False, help="Whether ruff check with --extend-select=UP"),
1535
    sim: bool = Option(True, help="Whether ruff check with --extend-select=SIM"),
1536
    strict: bool = Option(False, help="Whether run mypy with --strict"),
1537
    ty: bool = Option(False, help="Whether use ty instead of mypy"),
1538
) -> None:
1539
    """Check code style without reformat"""
1540
    bandit = _ensure_bool(bandit)
5✔
1541
    up = _ensure_bool(up)
5✔
1542
    sim = _ensure_bool(sim)
5✔
1543
    skip_mypy = _ensure_bool(skip_mypy)
5✔
1544
    check(dry=dry, bandit=bandit, skip_mypy=skip_mypy, up=up, sim=sim, ty=ty)
5✔
1545

1546

1547
class Sync(DryRun):
5✔
1548
    def __init__(
5✔
1549
        self, filename: str, extras: str, save: bool, dry: bool = False
1550
    ) -> None:
1551
        self.filename = filename
5✔
1552
        self.extras = extras
5✔
1553
        self._save = save
5✔
1554
        super().__init__(dry=dry)
5✔
1555

1556
    def gen(self) -> str:
5✔
1557
        extras, save = self.extras, self._save
5✔
1558
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
1559
        filename = _quote_shell_arg(self.filename)
5✔
1560
        if not (tool := Project.get_manage_tool()):
5✔
1561
            if should_remove or not is_venv():
5✔
1562
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
1563
            return f"python -m pip install -r {filename}"
5✔
1564
        prefix = ""
5✔
1565
        if not is_venv():
5✔
1566
            prefix = f"{tool} run " + "--no-sync " * (tool == "uv")
5✔
1567
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
1568
        export_cmd = "uv export --no-hashes --all-extras --all-groups --frozen"
5✔
1569
        if tool in ("poetry", "pdm"):
5✔
1570
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
1571
            if tool == "poetry":
5✔
1572
                ensure_pip = ""
5✔
1573
                if not UpgradeDependencies.should_with_dev():
5✔
1574
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
1575
                if extras and isinstance(extras, str | list):
5✔
1576
                    export_cmd += f" --extras={_quote_shell_arg(str(extras))}"
5✔
1577
            elif check_call(prefix + "python -m pip --version"):
×
1578
                ensure_pip = ""
×
1579
        elif check_call(prefix + "python -m pip --version"):
5✔
1580
            ensure_pip = ""
5✔
1581
        install_cmd = (
5✔
1582
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
1583
        )
1584
        if should_remove and not save:
5✔
1585
            install_cmd += " && rm -f {0}"
5✔
1586
        return install_cmd.format(filename, prefix, export_cmd)
5✔
1587

1588

1589
@cli.command()
5✔
1590
def sync(
5✔
1591
    filename: str = "dev_requirements.txt",
1592
    extras: str = Option("", "--extras", "-E"),
1593
    save: bool = Option(
1594
        False, "--save", "-s", help="Whether save the requirement file"
1595
    ),
1596
    dry: bool = DryOption,
1597
) -> None:
1598
    """Export dependencies by poetry to a txt file then install by pip."""
1599
    Sync(filename, extras, save, dry=dry).run()
5✔
1600

1601

1602
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
1603
    for name in ("test.sh", "test.py"):
5✔
1604
        if (file := path / name).exists():
5✔
1605
            return file
5✔
1606
    return None
5✔
1607

1608

1609
def test(dry: bool, ignore_script: bool = False) -> None:
5✔
1610
    cwd = Path.cwd()
5✔
1611
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
1612
    script_dir = root / "scripts"
5✔
1613
    if not _ensure_bool(ignore_script) and (
5✔
1614
        test_script := _should_run_test_script(script_dir)
1615
    ):
1616
        cmd = _quote_shell_arg(test_script.relative_to(root).as_posix())
5✔
1617
        if test_script.suffix == ".py":
5✔
1618
            cmd = "python " + cmd
5✔
1619
        if cwd != root:
5✔
1620
            cmd = f"cd {_quote_shell_arg(root)} && " + cmd
5✔
1621
    else:
1622
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
1623
        if not is_venv() or not check_call("coverage --version"):
5✔
1624
            sep = " && "
5✔
1625
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
1626
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
1627
    exit_if_run_failed(cmd, dry=dry)
5✔
1628

1629

1630
@cli.command(name="test")
5✔
1631
def coverage_test(
5✔
1632
    dry: bool = DryOption,
1633
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1634
) -> None:
1635
    """Run unittest by pytest and report coverage"""
1636
    return test(dry, ignore_script)
5✔
1637

1638

1639
class Publish:
5✔
1640
    class CommandEnum(StrEnum):
5✔
1641
        poetry = "poetry publish --build"
5✔
1642
        pdm = "pdm publish"
5✔
1643
        uv = "uv build && uv publish"
5✔
1644
        twine = "python -m build && twine upload"
5✔
1645

1646
    @classmethod
5✔
1647
    def gen(cls, tool: str | None, verbose: bool) -> str:
5✔
1648
        if tool == "auto":
5✔
1649
            tool = Project.get_manage_tool() or ""
5✔
1650
        if tool:
5✔
1651
            if tool == "uv":
5✔
1652
                envs = ["UV_PUBLISH_INDEX", "UV_PUBLISH_URL", "UV_PUBLISH_TOKEN"]
5✔
1653
                if not any(os.getenv(i) for i in envs):
5✔
1654
                    if verbose:
5✔
1655
                        yellow_warn(f"Skip uv publish as envs ({envs}) not set")
×
1656
                    return "uv build"
5✔
1657
            elif tool == "pdm":
5✔
1658
                env = "PDM_PUBLISH_REPO"
×
1659
                if not os.getenv(env):
×
1660
                    if verbose:
×
1661
                        yellow_warn(f"Skip pdm publish as env ({env}) not set")
×
1662
                    return "pdm build"
×
1663
            return cls.CommandEnum[tool]
5✔
1664
        return cls.CommandEnum.twine
5✔
1665

1666

1667
@cli.command()
5✔
1668
def upload(
5✔
1669
    verbose: bool = False,
1670
    tool: str = ToolOption,
1671
    dry: bool = DryOption,
1672
) -> None:
1673
    """Shortcut for package publish"""
1674
    cmd = Publish.gen(_ensure_str(tool), verbose)
5✔
1675
    exit_if_run_failed(cmd, dry=dry)
5✔
1676

1677

1678
def should_use_just() -> bool:
5✔
1679
    if shutil.which("just") is None or load_bool("FASTDEVCLI_IGNORE_JUST_DEV"):
5✔
1680
        return False
5✔
1681
    d = Path.cwd()
5✔
1682
    for _ in range(5):
5✔
1683
        f = d / "justfile"
5✔
1684
        if f.exists():
5✔
1685
            return _prefer_just_dev(f)
5✔
1686
        if d.joinpath("pyproject.toml").exists():
×
1687
            break
×
1688
        d = d.parent
×
1689
    return False
×
1690

1691

1692
def _prefer_just_dev(f: Path) -> bool:
5✔
1693
    text = f.read_text(encoding="utf-8")
5✔
1694
    lines = text.splitlines()
5✔
1695
    dev_recipe = "dev *args:"
5✔
1696
    re_import = re.compile(r"import[?]? ")
5✔
1697
    has_import = False
5✔
1698
    total = len(lines)
5✔
1699
    for i, line in enumerate(lines):
5✔
1700
        if line.startswith(dev_recipe):
5✔
1701
            # Avoid cycle callback
1702
            command_lines = []
×
1703
            for j in range(i + 1, total):
×
1704
                try:
×
1705
                    s = lines[j]
×
1706
                except IndexError:
×
1707
                    break
×
1708
                if not s.startswith(" "):
×
1709
                    break
×
1710
                command_lines.append(s)
×
1711
            if not command_lines:  # Invalid justfile
×
1712
                return False
×
1713
            return all("fast dev" not in i for i in command_lines)
×
1714
        elif not has_import and re_import.match(line):
5✔
1715
            has_import = True
5✔
1716
    if has_import:
5✔
1717
        recipes = capture_cmd_output("just --list").splitlines()
5✔
1718
        for recipe in recipes:
5✔
1719
            recipe = recipe.strip()
5✔
1720
            if recipe.startswith(dev_recipe):
5✔
1721
                return "fast dev" not in recipe
×
1722
    return False
5✔
1723

1724

1725
def _load_fastapi_entrypoint() -> str:
5✔
NEW
1726
    with contextlib.suppress(FileNotFoundError, KeyError):
×
NEW
1727
        try:
×
NEW
1728
            toml_text = Project.load_toml_text()
×
NEW
1729
        except EnvError:
×
NEW
1730
            return ""
×
NEW
1731
        doc = tomllib.loads(toml_text)
×
NEW
1732
        return doc["tool"]["fastapi"]["entrypoint"]
×
NEW
1733
    return ""
×
1734

1735

1736
def _parse_serve_file(
5✔
1737
    uvicorn: bool | None, filename: str, cmd: str, args: list[str]
1738
) -> str:
1739
    if m := re.search(r"(.*):(\d+)$", filename):
5✔
1740
        h, p = m.group(1), m.group(2)
5✔
1741
        if h and "--host" not in str(args):
5✔
1742
            if h == "0":
5✔
1743
                args.append("--host=0.0.0.0")
5✔
1744
            else:
1745
                args.append(f"--host={h}")
5✔
1746
        args.append(f"--port={p}")
5✔
1747
        if uvicorn:
5✔
NEW
1748
            if entrypoint := _load_fastapi_entrypoint():
×
NEW
1749
                cmd += " " + entrypoint
×
1750
            else:
NEW
1751
                p = Path("main.py")
×
NEW
1752
                if p.exists():
×
NEW
1753
                    cmd += " main:app"
×
NEW
1754
                elif Path("app", p.name).exists():
×
NEW
1755
                    cmd += " app.main:app"
×
NEW
1756
                elif Path("app.py").exists():
×
NEW
1757
                    cmd += " app:app"
×
1758
        return cmd
5✔
1759
    if uvicorn and ((filepath := Path(filename)).is_file() or filepath.suffix == ".py"):
5✔
1760
        filename = filepath.stem + ":app"
5✔
1761
        parent_names = [j for i in filepath.parents if (j := i.name)]
5✔
1762
        if parent_names:
5✔
1763
            filename = ".".join([*parent_names[::-1], filename])
5✔
1764
    cmd += " " + _quote_shell_arg(filename)
5✔
1765
    return cmd
5✔
1766

1767

1768
def _runserver(
5✔
1769
    uvicorn: bool | None,
1770
    host: OptionInfo | str | None,
1771
    port: OptionInfo | int | None,
1772
    file: ArgumentInfo | str | None,
1773
) -> tuple[str, list[str]]:
1774
    cmd = "uvicorn" if uvicorn else "fastapi dev"
5✔
1775
    args = []
5✔
1776
    if (host := getattr(host, "default", host)) and host not in (
5✔
1777
        "localhost",
1778
        "127.0.0.1",
1779
    ):
1780
        args.append(f"--host={host}")
5✔
1781
    no_port_yet = True
5✔
1782
    if file is not None:
5✔
1783
        filename = str(file)
5✔
1784
        try:
5✔
1785
            port = int(filename)
5✔
1786
        except ValueError:
5✔
1787
            cmd = _parse_serve_file(uvicorn, filename, cmd, args)
5✔
1788
        else:
1789
            if port != 8000:
5✔
1790
                args.append(f"--port={port}")
5✔
1791
                no_port_yet = False
5✔
1792
    elif uvicorn and (entrypoint := _load_fastapi_entrypoint()):
5✔
NEW
1793
        cmd += " " + entrypoint
×
1794
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
1795
        args.append(f"--port={port}")
5✔
1796
    if shutil.which("pdm") is not None:
5✔
1797
        cmd = "pdm run " + cmd
5✔
1798
    return cmd, args
5✔
1799

1800

1801
def dev(
5✔
1802
    port: int | None | OptionInfo,
1803
    host: str | None | OptionInfo,
1804
    fastapi: bool | None = None,
1805
    uvicorn: bool | None = None,
1806
    prod: bool | None = None,
1807
    reload: bool | None = None,
1808
    just: bool | None = None,
1809
    file: str | None | ArgumentInfo = None,
1810
    dry: bool = False,
1811
) -> None:
1812
    if just is not False and should_use_just():
5✔
1813
        args = [i for i in sys.argv[2:] if i != "--dry"]
×
1814
        cmd = "just dev"
×
1815
    else:
1816
        cmd, args = _runserver(uvicorn, host, port, file)
5✔
1817
    if args:
5✔
1818
        cmd += " " + _join_shell_args(args)
5✔
1819
    exit_if_run_failed(cmd, dry=dry)
5✔
1820

1821

1822
@cli.command(name="dev")
5✔
1823
def runserver(
5✔
1824
    file_or_port: str | None = typer.Argument(default=None),
1825
    port: int | None = Option(None, "-p", "--port"),
1826
    host: str | None = Option(None, "-h", "--host"),
1827
    fastapi: bool | None = None,
1828
    uvicorn: bool | None = None,
1829
    prod: bool | None = None,
1830
    reload: bool | None = None,
1831
    just: bool | None = None,
1832
    dry: bool = DryOption,
1833
) -> None:
1834
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1835
    f = functools.partial(
5✔
1836
        dev, port, host, fastapi, uvicorn, prod, reload, just, dry=dry
1837
    )
1838
    if getattr(file_or_port, "default", file_or_port):
5✔
1839
        f(file=file_or_port)
5✔
1840
    else:
1841
        f()
5✔
1842

1843

1844
@cli.command(name="exec")
5✔
1845
def run_by_subprocess(cmd: str, dry: bool = DryOption) -> None:
5✔
1846
    """Run cmd by subprocess, auto set shell=True when cmd contains '|>'"""
1847
    try:
5✔
1848
        rc = run_and_echo(cmd, verbose=True, dry=_ensure_bool(dry))
5✔
1849
    except FileNotFoundError as e:
5✔
1850
        command = cmd.split()[0]
5✔
1851
        if e.filename == command or (
5✔
1852
            e.filename is None and "系统找不到指定的文件" in str(e)
1853
        ):
1854
            echo(f"Command not found: {command}")
5✔
1855
            raise Exit(1) from None
5✔
1856
        raise
×
1857
    else:
1858
        if rc:
5✔
1859
            raise Exit(rc)
5✔
1860

1861

1862
class MakeDeps(DryRun):
5✔
1863
    def __init__(
5✔
1864
        self,
1865
        tool: str,
1866
        prod: bool = False,
1867
        dry: bool = False,
1868
        active: bool = False,
1869
        inexact: bool = False,
1870
        no_dev: bool = False,
1871
        verbose: bool = False,
1872
        frozen: bool = False,
1873
        no_extra: list[str] | None = None,
1874
        no_group: list[str] | None = None,
1875
    ) -> None:
1876
        self._tool = tool
5✔
1877
        self._prod = prod
5✔
1878
        self._active = active or load_bool("FASTDEVCLI_DEPS_ACTIVE")
5✔
1879
        self._inexact = inexact or load_bool("FASTDEVCLI_DEPS_INEXACT")
5✔
1880
        self._verbose = verbose
5✔
1881
        self._frozen = frozen
5✔
1882
        self._no_dev = no_dev
5✔
1883
        self._no_extra = no_extra
5✔
1884
        self._no_group = no_group
5✔
1885
        super().__init__(dry=dry)
5✔
1886

1887
    def should_ensure_pip(self) -> bool:
5✔
1888
        return True
5✔
1889

1890
    def should_upgrade_pip(self) -> bool:
5✔
1891
        return True
5✔
1892

1893
    def get_groups(self) -> list[str]:
5✔
1894
        if self._prod:
5✔
1895
            return []
5✔
1896
        return ["dev"]
5✔
1897

1898
    def gen(self) -> str:
5✔
1899
        cmd = self._gen()
5✔
1900
        if self._verbose:
5✔
1901
            cmd += " --verbose"
×
1902
        if self._no_dev:
5✔
1903
            opt = " --no-dev"
×
1904
            if opt not in cmd:
×
1905
                cmd += opt
×
1906
        if self._no_extra:
5✔
1907
            cmd += " " + " ".join(
5✔
1908
                f"--no-extra {_quote_shell_arg(i)}" for i in self._no_extra
1909
            )
1910
        if self._no_group:
5✔
1911
            cmd += " " + " ".join(
5✔
1912
                f"--no-group {_quote_shell_arg(i)}" for i in self._no_group
1913
            )
1914
        if self._frozen:
5✔
1915
            cmd += " --frozen"
×
1916
        if opts := os.getenv("FASTDEVCLI_DEPS_OPTS"):
5✔
1917
            cmd += " " + opts.strip()
×
1918
        return cmd
5✔
1919

1920
    def get_package_name(self) -> str:
5✔
1921
        with contextlib.suppress(FileNotFoundError, KeyError):
5✔
1922
            try:
5✔
1923
                toml_text = Project.load_toml_text()
5✔
1924
            except EnvError:
×
1925
                return ""
×
1926
            doc = tomllib.loads(toml_text)
5✔
1927
            tool_section = doc["tool"]
5✔
1928
            uv_package = tool_section.get("uv", {}).get("package")
5✔
1929
            if uv_package is not None:
5✔
1930
                if not uv_package:
×
1931
                    return ""
×
1932
            else:
1933
                match doc["build-system"]["build-backend"]:
5✔
1934
                    case "pdm.backend":
5✔
1935
                        if not tool_section.get("pdm", {}).get("distribution", True):
5✔
1936
                            return ""
×
1937
                    case x if x.startswith("poetry"):
×
1938
                        if not tool_section.get("poetry", {}).get("package-mode", True):
×
1939
                            return ""
×
1940
            return cast(str, doc["project"]["name"])
5✔
1941
        return ""
×
1942

1943
    def _gen(self) -> str:
5✔
1944
        if self._tool == "pdm":
5✔
1945
            return "pdm install --frozen " + ("--prod" if self._prod else "-G :all")
5✔
1946
        elif self._tool == "uv":
5✔
1947
            uv_sync = "uv sync"
5✔
1948
            if project := self.get_package_name():
5✔
1949
                uv_sync += " " + _quote_shell_arg(f"--reinstall-package={project}")
5✔
1950
            uv_sync += " --inexact" * self._inexact + " --active" * self._active
5✔
1951
            return uv_sync + (
5✔
1952
                " --no-dev" if self._prod else " --all-extras --all-groups"
1953
            )
1954
        elif self._tool == "poetry":
5✔
1955
            return "poetry install " + (
5✔
1956
                "--only=main" if self._prod else "--all-extras --all-groups"
1957
            )
1958
        else:
1959
            cmd = "python -m pip install -e ."
5✔
1960
            if gs := self.get_groups():
5✔
1961
                cmd += " " + " ".join(f"--group {g}" for g in gs)
5✔
1962
            upgrade = "python -m pip install --upgrade pip"
5✔
1963
            if self.should_ensure_pip():
5✔
1964
                cmd = f"python -m ensurepip && {upgrade} && {cmd}"
5✔
1965
            elif self.should_upgrade_pip():
5✔
1966
                cmd = f"{upgrade} && {cmd}"
5✔
1967
            return cmd
5✔
1968

1969

1970
@cli.command(name="deps")
5✔
1971
def make_deps(
5✔
1972
    prod: bool = Option(
1973
        False,
1974
        "--prod",
1975
        help="Only instead production dependencies.",
1976
    ),
1977
    tool: str = ToolOption,
1978
    use_uv: bool = Option(False, "--uv", help="Use `uv` to install deps"),
1979
    use_pdm: bool = Option(False, "--pdm", help="Use `pdm` to install deps"),
1980
    use_pip: bool = Option(False, "--pip", help="Use `pip` to install deps"),
1981
    use_poetry: bool = Option(False, "--poetry", help="Use `poetry` to install deps"),
1982
    active: bool = Option(
1983
        False, help="Add `--active` to uv sync command(Only work for uv project)"
1984
    ),
1985
    inexact: bool = Option(
1986
        False, help="Add `--inexact` to uv sync command(Only work for uv project)"
1987
    ),
1988
    no_dev: bool = Option(False, "--no-dev"),
1989
    no_extra: Annotated[list[str] | None, Option()] = None,
1990
    no_group: Annotated[list[str] | None, Option()] = None,
1991
    frozen: bool = Option(False, "--frozen", "--frozen-lockfile", "--no-lock"),
1992
    verbose: bool = Option(False, "--verbose"),
1993
    dry: bool = DryOption,
1994
) -> None:
1995
    """Run: ruff check/format to reformat code and then mypy to check"""
1996
    if use_uv + use_pdm + use_pip + use_poetry > 1:
×
1997
        raise typer.BadParameter(
×
1998
            "can only choose one",
1999
            param_hint=("--uv", "--pdm", "--pip", "--poetry"),
2000
        )
2001
    if use_uv:
×
2002
        tool = "uv"
×
2003
    elif use_pdm:
×
2004
        tool = "pdm"
×
2005
    elif use_pip:
×
2006
        tool = "pip"
×
2007
    elif use_poetry:
×
2008
        tool = "poetry"
×
2009
    elif tool == ToolOption.default:
×
2010
        tool = Project.get_manage_tool(cache=True) or "pip"
×
2011
    bool_opts = {
×
2012
        "active": active,
2013
        "inexact": inexact,
2014
        "no_dev": no_dev,
2015
        "verbose": verbose,
2016
        "frozen": frozen,
2017
        "dry": dry,
2018
    }
2019
    MakeDeps(tool, prod, no_extra=no_extra, no_group=no_group, **bool_opts).run()
×
2020

2021

2022
class UvPypi(DryRun):
5✔
2023
    PYPI = "https://pypi.org/simple"
5✔
2024
    HOST = "https://files.pythonhosted.org"
5✔
2025

2026
    def __init__(
5✔
2027
        self,
2028
        lock_file: Path,
2029
        dry: bool,
2030
        verbose: bool,
2031
        quiet: bool,
2032
        slim: bool = False,
2033
        reverse: bool = False,
2034
    ) -> None:
2035
        super().__init__(dry=dry)
5✔
2036
        self.lock_file = lock_file
5✔
2037
        self._verbose = _ensure_bool(verbose)
5✔
2038
        self._quiet = _ensure_bool(quiet)
5✔
2039
        self._slim = _ensure_bool(slim)
5✔
2040
        self._reverse = _ensure_bool(reverse)
5✔
2041

2042
    def run(self) -> None:
5✔
2043
        try:
5✔
2044
            rc = self.update_lock(
5✔
2045
                self.lock_file, self._verbose, self._quiet, self._slim, self._reverse
2046
            )
2047
        except ValueError as e:
×
2048
            secho(str(e), fg=typer.colors.RED)
×
2049
            raise Exit(1) from e
×
2050
        else:
2051
            if rc != 0:
5✔
2052
                raise Exit(rc)
5✔
2053

2054
    @staticmethod
5✔
2055
    def get_target_content(
5✔
2056
        text: str, verbose: bool, target_registry: str, target_host: str
2057
    ) -> str | None:
2058
        registry_pattern = r'(registry = ")(.*?)"'
5✔
2059
        registry_urls = {i[1] for i in re.findall(registry_pattern, text)}
5✔
2060
        download_pattern = r'(url = ")(https?://.*?)(/packages/.*?\.)(gz|whl|zip)"'
5✔
2061
        download_hosts = {i[1] for i in re.findall(download_pattern, text)}
5✔
2062
        if not registry_urls:
5✔
2063
            raise ValueError(
×
2064
                f"Failed to find pattern {registry_pattern!r} in uv lock file"
2065
            )
2066

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

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

2073
        if len(registry_urls) == 1:
5✔
2074
            current_registry = registry_urls.pop()
5✔
2075
            if current_registry == target_registry:
5✔
2076
                if download_hosts == {target_host}:
5✔
2077
                    return None
5✔
2078
            else:
2079
                text = replace_registry(text)
5✔
2080
                if verbose:
5✔
2081
                    echo(f"{current_registry} --> {target_registry}")
5✔
2082
        else:
2083
            # TODO: ask each one to confirm replace
2084
            text = replace_registry(text)
×
2085
            if verbose:
×
2086
                for current_registry in sorted(registry_urls):
×
2087
                    echo(f"{current_registry} --> {target_registry}")
×
2088
        if len(download_hosts) == 1:
5✔
2089
            current_host = download_hosts.pop()
5✔
2090
            if current_host != target_host:
5✔
2091
                text = replace_host(text)
5✔
2092
                if verbose:
5✔
2093
                    echo(f"{current_host} --> {target_host}")
5✔
2094
        elif download_hosts:
×
2095
            # TODO: ask each one to confirm replace
2096
            text = replace_host(text)
×
2097
            if verbose:
×
2098
                for current_host in sorted(download_hosts):
×
2099
                    echo(f"{current_host} --> {target_host}")
×
2100
        return text
5✔
2101

2102
    @classmethod
5✔
2103
    def update_lock(
5✔
2104
        cls,
2105
        p: Path,
2106
        verbose: bool,
2107
        quiet: bool,
2108
        slim: bool = False,
2109
        reverse: bool = False,
2110
    ) -> int:
2111
        text = p.read_text("utf-8")
5✔
2112
        target_register, target_host = cls.PYPI, cls.HOST
5✔
2113
        if reverse:
5✔
2114
            try:
5✔
2115
                target_register, target_host = cls.get_register_from_uv_config()
5✔
2116
            except FileNotFoundError:
5✔
2117
                if verbose:
5✔
2118
                    echo("Skip register reverse as global uv config file not found.")
5✔
2119
                if quiet:
5✔
2120
                    return 0
5✔
2121
                return 1
×
2122
        new_text = cls.get_target_content(text, verbose, target_register, target_host)
5✔
2123
        if new_text is None:
5✔
2124
            if verbose:
5✔
2125
                echo(f"Registry of {p} is {target_register}, no need to change.")
5✔
2126
            return 0
5✔
2127
        return cls.slim_and_write(new_text, slim, p, verbose, quiet)
5✔
2128

2129
    @classmethod
5✔
2130
    def get_register_from_uv_config(cls) -> tuple[str, str]:
5✔
2131
        config_file = cls.get_uv_config_file()
5✔
2132
        text = config_file.read_text("utf-8")
5✔
2133
        doc = tomllib.loads(text)
5✔
2134
        index_url = doc["index"][0]["url"]
5✔
2135
        return index_url, index_url.replace("/simple", "").rstrip("/")
5✔
2136

2137
    @staticmethod
5✔
2138
    def get_uv_config_file() -> Path:
5✔
2139
        config_dir = "AppData/Roaming" if is_windows() else ".config"
5✔
2140
        return Path.home() / config_dir / "uv/uv.toml"
5✔
2141

2142
    @staticmethod
5✔
2143
    def slim_and_write(
5✔
2144
        text: str, slim: bool, p: Path, verbose: bool, quiet: bool
2145
    ) -> int:
2146
        if slim:
5✔
2147
            pattern = r', size = \d+, upload-time = ".*?"'
5✔
2148
            text = re.sub(pattern, "", text)
5✔
2149
        size = p.write_text(text, encoding="utf-8")
5✔
2150
        if verbose:
5✔
2151
            echo(f"Updated {p} with {size} bytes.")
5✔
2152
        if quiet:
5✔
2153
            return 0
5✔
2154
        return 1
5✔
2155

2156

2157
@cli.command()
5✔
2158
def pypi(
5✔
2159
    file: str | None = typer.Argument(default=None),
2160
    dry: bool = DryOption,
2161
    verbose: bool = False,
2162
    quiet: bool = False,
2163
    slim: bool = False,
2164
    reverse: bool = False,
2165
) -> None:
2166
    """Change registry of uv.lock to be pypi.org"""
2167
    if not (p := Path(_ensure_str(file) or "uv.lock")).exists() and not (
5✔
2168
        (p := Project.get_work_dir() / p.name).exists()
2169
    ):
2170
        yellow_warn(f"{p.name!r} not found!")
5✔
2171
        return
5✔
2172
    UvPypi(p, dry, verbose, quiet, slim, reverse).run()
5✔
2173

2174

2175
def version_callback(value: bool) -> None:
5✔
2176
    if value:
5✔
2177
        echo("Fast Dev Cli Version: " + typer.style(__version__, bold=True))
5✔
2178
        raise Exit()
5✔
2179

2180

2181
@cli.callback()
2182
def common(
2183
    version: bool = Option(
2184
        None,
2185
        "--version",
2186
        "-V",
2187
        callback=version_callback,
2188
        is_eager=True,
2189
        help="Show the version of this tool",
2190
    ),
2191
) -> None: ...
2192

2193

2194
def main() -> None:
5✔
2195
    cli()
5✔
2196

2197

2198
if __name__ == "__main__":  # pragma: no cover
2199
    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