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

waketzheng / fast-dev-cli / 31293514208

09 Aug 2026 03:57AM UTC coverage: 81.207% (+0.09%) from 81.119%
31293514208

push

github

waketzheng
Fix test error

1171 of 1442 relevant lines covered (81.21%)

4.06 hits per line

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

81.19
/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
    @staticmethod
5✔
597
    def parse_new_version(part: str, version: str) -> str:
5✔
598
        version_parts = version.split(".")
5✔
599
        if not version_parts[-1].isdigit():
5✔
600
            try:
×
601
                p1, p2, p3 = version_parts[:3]
×
602
                p2i = int(p2)
×
603
                p1i = int(p1)
×
604
            except ValueError:
×
605
                ...
606
            else:
607
                match part:
×
608
                    case "patch":
×
609
                        p3i = int(m.group()) if (m := re.match(r"\d+", p3)) else 0
×
610
                        if len(version_parts) == 3:
×
611
                            p3i += 1
×
612
                        return f"{p1}.{p2}.{p3i}"
×
613
                    case "minor":
×
614
                        return f"{p1}.{p2i + 1}.0"
×
615
                    case "major":
×
616
                        return f"{p1i + 1}.0.0"
×
617
        return ""
5✔
618

619
    def gen(self) -> str:
5✔
620
        should_sync, _version = get_current_version(check_version=True)
5✔
621
        filename = self.filename
5✔
622
        echo(f"Current version(@{filename}): {_version}")
5✔
623
        if self.part:
5✔
624
            part = self.get_part(self.part)
5✔
625
        else:
626
            part = "patch"
5✔
627
            if a := input("Which one?").strip():
5✔
628
                part = self.get_part(a)
5✔
629
        self.part = part
5✔
630
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5✔
631
        filename_arg = _quote_shell_arg(filename)
5✔
632
        cmd = f'bumpversion {parse} --current-version="{_version}" '
5✔
633
        if new_version := self.parse_new_version(part, _version):
5✔
634
            cmd += f'--new-version="{new_version}" '
×
635
        cmd += f"{part} {filename_arg}"
5✔
636
        if self.commit:
5✔
637
            if part != "patch":
5✔
638
                cmd += " --tag"
5✔
639
            cmd += " --commit"
5✔
640
            if self._emoji or (self._emoji is None and self.should_add_emoji()):
5✔
641
                cmd += " --message-emoji=1"
5✔
642
            if not load_bool("DONT_GIT_PUSH"):
5✔
643
                cmd += " && git push && git push --tags && git log -1"
5✔
644
        else:
645
            cmd += " --allow-dirty"
5✔
646
        if (
5✔
647
            should_sync
648
            and not self._no_sync
649
            and (sync := Project.get_sync_command(only_me=True))
650
        ):
651
            cmd = f"{sync} && " + cmd
5✔
652
        return cmd
5✔
653

654
    def run(self) -> None:
5✔
655
        super().run()
5✔
656
        if not self.commit and not self.dry:
5✔
657
            new_version = get_current_version(True)
5✔
658
            echo(new_version)
5✔
659
            if self.part != "patch":
5✔
660
                echo("You may want to pin tag by `fast tag`")
5✔
661

662

663
def _echo_version(version_file: Any, value: str) -> None:
5✔
664
    styled = typer.style(value, bold=True)
5✔
665
    echo(f"Version value in {version_file}: " + styled)
5✔
666

667

668
@cli.command()
5✔
669
def version() -> None:
5✔
670
    """Show the version of this tool"""
671
    echo("Fast Dev Cli Version: " + typer.style(__version__, fg=typer.colors.BLUE))
5✔
672
    with contextlib.suppress(FileNotFoundError, KeyError):
5✔
673
        try:
5✔
674
            toml_text = Project.load_toml_text()
5✔
675
        except EnvError:
×
676
            if (res := _get_frontend_version()) is not None:
×
677
                _echo_version(*res)
×
678
                return
×
679
            raise
×
680
        doc = tomllib.loads(toml_text)
5✔
681
        if value := doc.get("project", {}).get("version", ""):
5✔
682
            styled = typer.style(value, bold=True, fg=typer.colors.CYAN)
×
683
            if project_name := doc["project"].get("name", ""):
×
684
                echo(f"{project_name} version: " + styled)
×
685
            else:
686
                echo(f"Got Version from {TOML_FILE}: " + styled)
×
687
            return
×
688
        version_file = doc["tool"]["pdm"]["version"]["path"]
5✔
689
        text = Project.get_work_dir().joinpath(version_file).read_text(encoding="utf-8")
5✔
690
        varname = "__version__"
5✔
691
        for line in text.splitlines():
5✔
692
            if line.strip().startswith(varname):
5✔
693
                value = line.split("=", 1)[-1].strip().strip('"').strip("'")
5✔
694
                _echo_version(version_file, value)
5✔
695
                break
5✔
696

697

698
@cli.command(name="bump")
5✔
699
def bump_version(
5✔
700
    part: BumpUp.PartChoices,
701
    sync: bool = False,
702
    commit: bool = Option(
703
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
704
    ),
705
    emoji: bool | None = Option(
706
        None, "--emoji", help="Whether add emoji prefix to commit message"
707
    ),
708
    dry: bool = DryOption,
709
) -> None:
710
    """Bump up version string in pyproject.toml"""
711
    if emoji is not None:
5✔
712
        emoji = _ensure_bool(emoji)
5✔
713
    return BumpUp(
5✔
714
        _ensure_bool(commit),
715
        getattr(part, "value", part),
716
        no_sync=not _ensure_bool(sync),
717
        emoji=emoji,
718
        dry=dry,
719
    ).run()
720

721

722
def bump() -> None:
5✔
723
    part, commit = "", False
5✔
724
    if args := sys.argv[2:]:
5✔
725
        if "-c" in args or "--commit" in args:
5✔
726
            commit = True
5✔
727
        for a in args:
5✔
728
            if not a.startswith("-"):
5✔
729
                part = a
5✔
730
                break
5✔
731
    return BumpUp(commit, part, no_sync="--no-sync" in args, dry="--dry" in args).run()
5✔
732

733

734
class Project:
5✔
735
    path_depth = 5
5✔
736
    _tool: ToolName | None = None
5✔
737

738
    @staticmethod
5✔
739
    def is_poetry_v2(text: str) -> bool:
5✔
740
        return 'build-backend = "poetry' in text
5✔
741

742
    @staticmethod
5✔
743
    def get_poetry_version(command: str = "poetry") -> str:
5✔
744
        pattern = r"(\d+\.\d+\.\d+)"
5✔
745
        text = capture_cmd_output(f"{command} --version")
5✔
746
        for expr in (
5✔
747
            rf"Poetry \(version {pattern}\)",
748
            rf"Poetry.*version.*{pattern}.*\)",
749
            rf"{pattern}",
750
        ):
751
            if m := re.search(expr, text):
5✔
752
                return m.group(1)
5✔
753
        return ""
×
754

755
    @staticmethod
5✔
756
    def work_dir(
5✔
757
        name: str, parent: Path, depth: int, be_file: bool = False
758
    ) -> Path | None:
759
        for _ in range(depth):
5✔
760
            if (f := parent.joinpath(name)).exists():
5✔
761
                if be_file:
5✔
762
                    return f
5✔
763
                return parent
5✔
764
            parent = parent.parent
5✔
765
        return None
5✔
766

767
    @classmethod
5✔
768
    def get_work_dir(
5✔
769
        cls: type[Self],
770
        name: str = TOML_FILE,
771
        cwd: Path | None = None,
772
        allow_cwd: bool = False,
773
        be_file: bool = False,
774
    ) -> Path:
775
        cwd = cwd or Path.cwd()
5✔
776
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5✔
777
            return d
5✔
778
        if allow_cwd:
5✔
779
            return cls.get_root_dir(cwd)
5✔
780
        raise EnvError(f"{name} not found! Make sure this is a python project.")
5✔
781

782
    @classmethod
5✔
783
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
5✔
784
        toml_file = cls.get_work_dir(name, be_file=True)
5✔
785
        return toml_file.read_text("utf8")
5✔
786

787
    @classmethod
5✔
788
    def manage_by_poetry(cls: type[Self], cache: bool = False) -> bool:
5✔
789
        return cls.get_manage_tool(cache=cache) == "poetry"
5✔
790

791
    @classmethod
5✔
792
    def get_manage_tool(cls: type[Self], cache: bool = False) -> ToolName | None:
5✔
793
        if cache and cls._tool:
5✔
794
            return cls._tool
5✔
795
        try:
5✔
796
            text = cls.load_toml_text()
5✔
797
        except EnvError:
5✔
798
            return None
5✔
799
        backend = ""
5✔
800
        skip_uv = load_bool("FASTDEVCLI_SKIP_UV")
5✔
801
        with contextlib.suppress(KeyError, tomllib.TOMLDecodeError):
5✔
802
            doc = tomllib.loads(text)
5✔
803
            backend = doc["build-system"]["build-backend"]
5✔
804
            if skip_uv:
5✔
805
                for t in ("pdm", "poetry"):
×
806
                    if t in backend:
×
807
                        cls._tool = t
×
808
                        return cls._tool
×
809
        return cls._get_manage_tool(text, backend, skip_uv)
5✔
810

811
    @classmethod
5✔
812
    def _get_manage_tool(
5✔
813
        cls: type[Self], text: str, backend: str, skip_uv: bool
814
    ) -> ToolName | None:
815
        work_dir: Path | None = None
5✔
816
        uv_lock_exists: bool | None = None
5✔
817
        if skip_uv:
5✔
818
            for name in ("pdm", "poetry"):
×
819
                if f"[tool.{name}]" in text:
×
820
                    cls._tool = name
×
821
                    return cls._tool
×
822
            work_dir = cls.get_work_dir(allow_cwd=True)
×
823
            for name in ("pdm", "poetry"):
×
824
                if Path(work_dir, f"{name}.lock").exists():
×
825
                    cls._tool = cast(ToolName, name)
×
826
                    return cls._tool
×
827
            if uv_lock_exists := Path(work_dir, "uv.lock").exists():
×
828
                # Use pdm when uv is not available for uv managed project
829
                cls._tool = "pdm"
×
830
                return cls._tool
×
831
            return None
×
832
        if work_dir is None:
5✔
833
            work_dir = cls.get_work_dir(allow_cwd=True)
5✔
834
        if uv_lock_exists is None:
5✔
835
            uv_lock_exists = Path(work_dir, "uv.lock").exists()
5✔
836
        if uv_lock_exists:
5✔
837
            cls._tool = "uv"
5✔
838
            return cls._tool
5✔
839
        return cls._parse_manage_tool(work_dir, text, backend)
5✔
840

841
    @classmethod
5✔
842
    def _parse_manage_tool(
5✔
843
        cls: type[Self], work_dir: Path, text: str, backend: str
844
    ) -> ToolName | None:
845
        pdm_lock_exists = Path(work_dir, "pdm.lock").exists()
5✔
846
        poetry_lock_exists = Path(work_dir, "poetry.lock").exists()
5✔
847
        match pdm_lock_exists + poetry_lock_exists:
5✔
848
            case 1:
5✔
849
                cls._tool = "pdm" if pdm_lock_exists else "poetry"
×
850
                return cls._tool
×
851
            case _ as x:
5✔
852
                if backend:
5✔
853
                    for t in ("pdm", "poetry"):
5✔
854
                        if t in backend:
5✔
855
                            cls._tool = t
5✔
856
                            return cls._tool
5✔
857
                for name in ("pdm", "poetry"):
5✔
858
                    if f"[tool.{name}]" in text:
5✔
859
                        cls._tool = name
5✔
860
                        return cls._tool
5✔
861
                if x == 2:
5✔
862
                    cls._tool = (
×
863
                        "poetry" if load_bool("FASTDEVCLI_PREFER_POETRY") else "pdm"
864
                    )
865
                    return cls._tool
1✔
866
        if "[tool.uv]" in text or load_bool("FASTDEVCLI_PREFER_uv"):
5✔
867
            cls._tool = "uv"
5✔
868
            return cls._tool
5✔
869
        # Poetry 2.0 default to not include the '[tool.poetry]' section
870
        if cls.is_poetry_v2(text):
5✔
871
            cls._tool = "poetry"
×
872
            return cls._tool
×
873
        return None
5✔
874

875
    @staticmethod
5✔
876
    def python_exec_dir() -> Path:
5✔
877
        return Path(sys.executable).parent
5✔
878

879
    @classmethod
5✔
880
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5✔
881
        root = cwd or Path.cwd()
5✔
882
        venv_parent = cls.python_exec_dir().parent.parent
5✔
883
        if root.is_relative_to(venv_parent):
5✔
884
            root = venv_parent
5✔
885
        return root
5✔
886

887
    @classmethod
5✔
888
    def is_pdm_project(cls, strict: bool = True, cache: bool = False) -> bool:
5✔
889
        if cls.get_manage_tool(cache=cache) != "pdm":
5✔
890
            return False
5✔
891
        if strict:
×
892
            lock_file = cls.get_work_dir() / "pdm.lock"
×
893
            return lock_file.exists()
×
894
        return True
×
895

896
    @classmethod
5✔
897
    def get_sync_command(
5✔
898
        cls, prod: bool = True, doc: dict[str, Any] | None = None, only_me: bool = False
899
    ) -> str:
900
        pdm_i = "pdm install --frozen" + " --prod" * prod
5✔
901
        if cls.is_pdm_project():
5✔
902
            return pdm_i
×
903
        elif cls.manage_by_poetry(cache=True):
5✔
904
            cmd = "poetry install"
5✔
905
            if prod:
5✔
906
                if doc is None:
5✔
907
                    doc = tomllib.loads(cls.load_toml_text())
5✔
908
                if doc.get("project", {}).get("dependencies") or any(
5✔
909
                    i != "python"
910
                    for i in doc.get("tool", {})
911
                    .get("poetry", {})
912
                    .get("dependencies", [])
913
                ):
914
                    cmd += " --only=main"
×
915
            return cmd
5✔
916
        elif cls.get_manage_tool(cache=True) == "uv":
×
917
            install_me = "uv pip install -e ."
×
918
            if doc is None:
×
919
                doc = tomllib.loads(cls.load_toml_text())
×
920
            is_distribution = (
×
921
                doc.get("tool", {}).get("pdm", {}).get("distribution") is not False
922
            )
923
            if only_me:
×
924
                return install_me if is_distribution else pdm_i
×
925
            cmd = "uv sync --inexact" + " --no-dev" * prod
×
926
            if is_distribution:
×
927
                cmd += f" && {install_me}"
×
928
        return ""
×
929

930
    @classmethod
5✔
931
    def sync_dependencies(cls, prod: bool = True) -> None:
5✔
932
        if cmd := cls.get_sync_command():
×
933
            run_and_echo(cmd)
×
934

935

936
class UpgradeDependencies(Project, DryRun):
5✔
937
    def __init__(
5✔
938
        self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
939
    ) -> None:
940
        super().__init__(_exit, dry)
5✔
941
        self._tool = tool
5✔
942

943
    class DevFlag(StrEnum):
5✔
944
        new = "[tool.poetry.group.dev.dependencies]"
5✔
945
        old = "[tool.poetry.dev-dependencies]"
5✔
946

947
    @staticmethod
5✔
948
    def parse_value(version_info: str, key: str) -> str:
5✔
949
        """Pick out the value for key in version info.
950

951
        Example::
952
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
953
            >>> UpgradeDependencies.parse_value(s, 'extras')
954
            'all'
955
            >>> UpgradeDependencies.parse_value(s, 'optional')
956
            'true'
957
            >>> UpgradeDependencies.parse_value(s, 'version')
958
            '^0.9.0'
959
        """
960
        sep = key + " = "
5✔
961
        rest = version_info.split(sep, 1)[-1].strip(" =")
5✔
962
        if rest.startswith("["):
5✔
963
            rest = rest[1:].split("]")[0]
5✔
964
        elif rest.startswith('"'):
5✔
965
            rest = rest[1:].split('"')[0]
5✔
966
        else:
967
            rest = rest.split(",")[0].split("}")[0]
5✔
968
        return rest.strip().replace('"', "")
5✔
969

970
    @staticmethod
5✔
971
    def no_need_upgrade(version_info: str, line: str) -> bool:
5✔
972
        if (v := version_info.replace(" ", "")).startswith("{url="):
5✔
973
            echo(f"No need to upgrade for: {line}")
5✔
974
            return True
5✔
975
        if (f := "version=") in v:
5✔
976
            v = v.split(f)[1].strip('"').split('"')[0]
5✔
977
        if v == "*":
5✔
978
            echo(f"Skip wildcard line: {line}")
5✔
979
            return True
5✔
980
        elif v == "[":
5✔
981
            echo(f"Skip complex dependence: {line}")
5✔
982
            return True
5✔
983
        elif v.startswith((">", "<")) or v[0].isdigit():
5✔
984
            echo(f"Ignore bigger/smaller/equal: {line}")
5✔
985
            return True
5✔
986
        return False
5✔
987

988
    @classmethod
5✔
989
    def build_args(
5✔
990
        cls: type[Self], package_lines: list[str]
991
    ) -> tuple[list[str], dict[str, list[str]]]:
992
        args: list[str] = []  # ['typer[all]', 'fastapi']
5✔
993
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
5✔
994
        for no, line in enumerate(package_lines, 1):
5✔
995
            if (
5✔
996
                not (m := line.strip())
997
                or m.startswith("#")
998
                or m == "]"
999
                or (m.startswith("{") and m.strip(",").endswith("}"))
1000
            ):
1001
                continue
5✔
1002
            try:
5✔
1003
                package, version_info = m.split("=", 1)
5✔
1004
            except ValueError as e:
5✔
1005
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
5✔
1006
            if (package := package.strip()).lower() == "python":
5✔
1007
                continue
5✔
1008
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
5✔
1009
                continue
5✔
1010
            if (extras_tip := "extras") in version_info:
5✔
1011
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
5✔
1012
            item = f'"{package}@latest"'
5✔
1013
            key = None
5✔
1014
            if (pf := "platform") in version_info:
5✔
1015
                platform = cls.parse_value(version_info, pf)
5✔
1016
                key = f"--{pf}={platform}"
5✔
1017
            if (sc := "source") in version_info:
5✔
1018
                source = cls.parse_value(version_info, sc)
5✔
1019
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
5✔
1020
            if "optional = true" in version_info:
5✔
1021
                key = ("" if key is None else (key + " ")) + "--optional"
5✔
1022
            if key is not None:
5✔
1023
                specials[key] = specials.get(key, []) + [item]
5✔
1024
            else:
1025
                args.append(item)
5✔
1026
        return args, specials
5✔
1027

1028
    @classmethod
5✔
1029
    def should_with_dev(cls: type[Self]) -> bool:
5✔
1030
        text = cls.load_toml_text()
5✔
1031
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
1032

1033
    @staticmethod
5✔
1034
    def parse_item(toml_str: str) -> list[str]:
5✔
1035
        lines: list[str] = []
5✔
1036
        for line in toml_str.splitlines():
5✔
1037
            if (line := line.strip()).startswith("["):
5✔
1038
                if lines:
5✔
1039
                    break
5✔
1040
            elif line:
5✔
1041
                lines.append(line)
5✔
1042
        return lines
5✔
1043

1044
    @classmethod
5✔
1045
    def get_args(
5✔
1046
        cls: type[Self], toml_text: str | None = None
1047
    ) -> tuple[list[str], list[str], list[list[str]], str]:
1048
        if toml_text is None:
5✔
1049
            toml_text = cls.load_toml_text()
5✔
1050
        main_title = "[tool.poetry.dependencies]"
5✔
1051
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
5✔
1052
            toml_text
1053
        ):
1054
            raise EnvError(
5✔
1055
                f"{main_title} not found! Make sure this is a poetry project."
1056
            )
1057
        text = toml_text.split(main_title)[-1]
5✔
1058
        dev_flag = "--group dev"
5✔
1059
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
5✔
1060
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
5✔
1061
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
5✔
1062
            dev_flag = "--dev"
5✔
1063
        others: list[list[str]] = []
5✔
1064
        try:
5✔
1065
            main_toml, dev_toml = text.split(dev_title)
5✔
1066
        except ValueError:
5✔
1067
            dev_toml = ""
5✔
1068
            main_toml = text
5✔
1069
        mains = [] if no_main_deps else cls.parse_item(main_toml)
5✔
1070
        devs = cls.parse_item(dev_toml)
5✔
1071
        prod_packs, specials = cls.build_args(mains)
5✔
1072
        if specials:
5✔
1073
            others.extend([[k] + v for k, v in specials.items()])
5✔
1074
        dev_packs, specials = cls.build_args(devs)
5✔
1075
        if specials:
5✔
1076
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
5✔
1077
        return prod_packs, dev_packs, others, dev_flag
5✔
1078

1079
    @classmethod
5✔
1080
    def gen_cmd(cls: type[Self]) -> str:
5✔
1081
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
1082
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
1083

1084
    @staticmethod
5✔
1085
    def to_cmd(
5✔
1086
        main_args: list[str],
1087
        dev_args: list[str],
1088
        others: list[list[str]],
1089
        dev_flags: str,
1090
    ) -> str:
1091
        command = "poetry add "
5✔
1092
        _upgrade = ""
5✔
1093
        if main_args:
5✔
1094
            _upgrade = command + " ".join(main_args)
5✔
1095
        if dev_args:
5✔
1096
            if _upgrade:
5✔
1097
                _upgrade += " && "
5✔
1098
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5✔
1099
        for single in others:
5✔
1100
            _upgrade += f" && poetry add {' '.join(single)}"
5✔
1101
        return _upgrade
5✔
1102

1103
    def gen(self) -> str:
5✔
1104
        if self._tool == "uv":
5✔
1105
            up = "uv lock --upgrade --verbose"
5✔
1106
            deps = "uv sync --inexact --frozen --all-groups --all-extras"
5✔
1107
            return f"{up} && {deps}"
5✔
1108
        elif self._tool == "pdm":
5✔
1109
            return "pdm update --verbose && pdm install -G :all --frozen"
5✔
1110
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
1111

1112

1113
@cli.command()
5✔
1114
def upgrade(
5✔
1115
    tool: str = ToolOption,
1116
    dry: bool = DryOption,
1117
) -> None:
1118
    """Upgrade dependencies in pyproject.toml to latest versions"""
1119
    if not (tool := _ensure_str(tool) or "") or tool == ToolOption.default:
5✔
1120
        tool = Project.get_manage_tool() or "uv"
5✔
1121
    if tool in get_args(ToolName):
5✔
1122
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
5✔
1123
    else:
1124
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
5✔
1125
        raise typer.Exit(1)
5✔
1126

1127

1128
class GitTag(DryRun):
5✔
1129
    def __init__(self, message: str, dry: bool, no_sync: bool = False) -> None:
5✔
1130
        self.message = message
5✔
1131
        self._no_sync = no_sync
5✔
1132
        super().__init__(dry=dry)
5✔
1133

1134
    @staticmethod
5✔
1135
    def has_v_prefix() -> bool:
5✔
1136
        return "v" in capture_cmd_output("git tag")
5✔
1137

1138
    def should_push(self) -> bool:
5✔
1139
        return "git push" in self.git_status
5✔
1140

1141
    def gen(self) -> str:
5✔
1142
        should_sync, _version = get_current_version(verbose=False, check_version=True)
5✔
1143
        if self.has_v_prefix():
5✔
1144
            # Add `v` at prefix to compare with bumpversion tool
1145
            _version = "v" + _version
5✔
1146
        cmd = (
5✔
1147
            f"git tag -a {_quote_shell_arg(_version)} "
1148
            f"-m {_quote_shell_arg(self.message)} && git push --tags"
1149
        )
1150
        if self.should_push():
5✔
1151
            cmd += " && git push"
5✔
1152
        if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
5✔
1153
            cmd = f"{sync} && " + cmd
×
1154
        return cmd
5✔
1155

1156
    @cached_property
5✔
1157
    def git_status(self) -> str:
5✔
1158
        return capture_cmd_output("git status")
5✔
1159

1160
    def mark_tag(self) -> bool:
5✔
1161
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5✔
1162
            "无文件要提交,干净的工作区" not in self.git_status
1163
        ):
1164
            run_and_echo("git status")
5✔
1165
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5✔
1166
            return False
5✔
1167
        return bool(super().run())
5✔
1168

1169
    def run(self) -> None:
5✔
1170
        if self.mark_tag() and not self.dry:
5✔
1171
            echo("You may want to publish package:\n pdm publish")
5✔
1172

1173

1174
@cli.command()
5✔
1175
def tag(
5✔
1176
    message: str = Option("", "-m", "--message"),
1177
    no_sync: bool = Option(
1178
        False, "--no-sync", help="Do not run sync command to update version"
1179
    ),
1180
    dry: bool = DryOption,
1181
) -> None:
1182
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
1183
    GitTag(message, dry=dry, no_sync=_ensure_bool(no_sync)).run()
5✔
1184

1185

1186
class LintCode(DryRun):
5✔
1187
    def __init__(
5✔
1188
        self,
1189
        args: list[str] | str | None,
1190
        check_only: bool = False,
1191
        _exit: bool = False,
1192
        dry: bool = False,
1193
        bandit: bool = False,
1194
        skip_mypy: bool = False,
1195
        dmypy: bool = False,
1196
        tool: str = ToolOption.default,
1197
        prefix: bool = False,
1198
        up: bool = False,
1199
        sim: bool = True,
1200
        strict: bool = False,
1201
        ty: bool = False,
1202
        fix: bool = True,
1203
    ) -> None:
1204
        self.args = args
5✔
1205
        self.check_only = check_only
5✔
1206
        self._bandit = bandit
5✔
1207
        self._skip_mypy = skip_mypy
5✔
1208
        self._use_dmypy = dmypy
5✔
1209
        self._tool = tool
5✔
1210
        self._prefix = prefix
5✔
1211
        self._up = up
5✔
1212
        self._sim = sim
5✔
1213
        self._strict = strict
5✔
1214
        self._ty = _ensure_bool(ty)
5✔
1215
        self._fix = _ensure_bool(fix)
5✔
1216
        super().__init__(_exit, dry)
5✔
1217

1218
    @staticmethod
5✔
1219
    def check_lint_tool_installed() -> bool:
5✔
1220
        try:
5✔
1221
            return check_call("ruff --version")
5✔
1222
        except FileNotFoundError:
×
1223
            # Windows may raise FileNotFoundError when ruff not installed
1224
            return False
×
1225

1226
    @staticmethod
5✔
1227
    def missing_mypy_exec() -> bool:
5✔
1228
        return shutil.which("mypy") is None
5✔
1229

1230
    @staticmethod
5✔
1231
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
5✔
1232
        return (
5✔
1233
            paths == "."
1234
            and any(t.startswith("mypy") for t in tools)
1235
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
1236
        )
1237

1238
    @staticmethod
5✔
1239
    def get_package_name() -> str:
5✔
1240
        root = Project.get_work_dir(allow_cwd=True)
5✔
1241
        module_name = root.name.replace("-", "_").replace(" ", "_")
5✔
1242
        package_maybe = (module_name, "src")
5✔
1243
        for name in package_maybe:
5✔
1244
            if root.joinpath(name).is_dir():
5✔
1245
                return name
5✔
1246
        return "."
5✔
1247

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

1388
    def gen(self) -> str:
5✔
1389
        paths = ["."]
5✔
1390
        if args := self.args:
5✔
1391
            ps = shlex.split(args) if isinstance(args, str) else [str(i) for i in args]
5✔
1392
            if len(ps) == 1:
5✔
1393
                path = ps[0]
5✔
1394
                if (
5✔
1395
                    path != "."
1396
                    # `Path("a.").suffix` got "." in py3.14 and got "" with py<3.14
1397
                    and (p := Path(path)).suffix in ("", ".")
1398
                    and not p.exists()
1399
                ):
1400
                    # e.g.:
1401
                    # stem -> stem.py
1402
                    # me. -> me.py
1403
                    if path.endswith("."):
×
1404
                        p = p.with_name(path[:-1])
×
1405
                    for suffix in (".py", ".html"):
×
1406
                        p = p.with_suffix(suffix)
×
1407
                        if p.exists():
×
1408
                            ps[0] = p.name
×
1409
                            break
×
1410
            paths = ps
5✔
1411
        return self.to_cmd(
5✔
1412
            paths,
1413
            self.check_only,
1414
            self._bandit,
1415
            self._skip_mypy,
1416
            self._use_dmypy,
1417
            tool=self._tool,
1418
            with_prefix=self._prefix,
1419
            ruff_check_up=self._up,
1420
            ruff_check_sim=self._sim,
1421
            mypy_strict=self._strict,
1422
            prefer_ty=self._ty,
1423
            ruff_check_fix=self._fix,
1424
        )
1425

1426

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

1430

1431
def lint(
5✔
1432
    files: list[str] | str | None = None,
1433
    dry: bool = False,
1434
    bandit: bool = False,
1435
    skip_mypy: bool = False,
1436
    dmypy: bool = False,
1437
    tool: str = ToolOption.default,
1438
    prefix: bool = False,
1439
    up: bool = False,
1440
    sim: bool = True,
1441
    strict: bool = False,
1442
    ty: bool = False,
1443
    fix: bool = True,
1444
) -> None:
1445
    if files is None:
5✔
1446
        files = parse_files(sys.argv[1:])
5✔
1447
    if files and files[0] == "lint":
5✔
1448
        files = files[1:]
5✔
1449
    LintCode(
5✔
1450
        files,
1451
        dry=dry,
1452
        skip_mypy=skip_mypy,
1453
        bandit=bandit,
1454
        dmypy=dmypy,
1455
        tool=tool,
1456
        prefix=prefix,
1457
        up=up,
1458
        sim=sim,
1459
        strict=strict,
1460
        ty=ty,
1461
        fix=fix,
1462
    ).run()
1463

1464

1465
def check(
5✔
1466
    files: list[str] | str | None = None,
1467
    dry: bool = False,
1468
    bandit: bool = False,
1469
    skip_mypy: bool = False,
1470
    dmypy: bool = False,
1471
    tool: str = ToolOption.default,
1472
    up: bool = False,
1473
    sim: bool = True,
1474
    strict: bool = False,
1475
    ty: bool = False,
1476
) -> None:
1477
    LintCode(
5✔
1478
        files,
1479
        check_only=True,
1480
        _exit=True,
1481
        dry=dry,
1482
        bandit=bandit,
1483
        skip_mypy=skip_mypy,
1484
        dmypy=dmypy,
1485
        tool=tool,
1486
        up=up,
1487
        sim=sim,
1488
        strict=strict,
1489
        ty=ty,
1490
    ).run()
1491

1492

1493
def _should_bandit() -> bool:
5✔
1494
    if v := os.getenv("FASTDEVCLI_BANDIT"):
×
1495
        return _convert_bool(v) or True
×
1496
    toml_text = Project.load_toml_text()
×
1497
    lines = toml_text.splitlines()
×
1498
    return any(i.startswith("[tool.bandit") for i in lines)
×
1499

1500

1501
@cli.command(name="lint")
5✔
1502
def make_style(
5✔
1503
    files: list[str] | None = typer.Argument(default=None),  # noqa:B008
1504
    check_only: bool = Option(False, "--check-only", "-c"),
1505
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1506
    prefix: bool = Option(
1507
        False,
1508
        "--prefix",
1509
        help="Run lint command with tool prefix, e.g.: pdm run ruff ...",
1510
    ),
1511
    skip_mypy: bool = Option(False, "--skip-mypy"),
1512
    use_dmypy: bool = Option(
1513
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
1514
    ),
1515
    tool: str = ToolOption,
1516
    dry: bool = DryOption,
1517
    up: bool = Option(False, help="Whether ruff check with --extend-select=UP"),
1518
    sim: bool = Option(True, help="Whether ruff check with --extend-select=SIM"),
1519
    strict: bool = Option(False, help="Whether run mypy with --strict"),
1520
    ty: bool = Option(False, help="Whether use ty instead of mypy"),
1521
    fix: bool | None = Option(None, help="Whether ruff check with --fix"),
1522
    auto_bandit: bool | None = Option(
1523
        None, help="Whether to run bandit if `[tool.bandit]` in pyproject.toml"
1524
    ),
1525
) -> None:
1526
    """Run: ruff check/format to reformat code and then mypy to check"""
1527
    if getattr(files, "default", files) is None:
5✔
1528
        files = ["."]
5✔
1529
    elif isinstance(files, str):
5✔
1530
        files = [files]
5✔
1531
    skip = _ensure_bool(skip_mypy)
5✔
1532
    dmypy = _ensure_bool(use_dmypy)
5✔
1533
    bandit = _ensure_bool(bandit) or (
5✔
1534
        auto_bandit is not None and _ensure_bool(auto_bandit) and _should_bandit()
1535
    )
1536
    tool = _ensure_str(tool) or ""
5✔
1537
    up = _ensure_bool(up)
5✔
1538
    sim = _ensure_bool(sim)
5✔
1539
    strict = _ensure_bool(strict)
5✔
1540
    kwargs = {"dry": dry, "skip_mypy": skip, "dmypy": dmypy, "bandit": bandit}
5✔
1541
    if _ensure_bool(check_only):
5✔
1542
        run = check
5✔
1543
    else:
1544
        prefix = _ensure_bool(prefix)
5✔
1545
        if fix is None or not isinstance(fix, bool):
5✔
1546
            fix = load_bool("FASTDEVCLI_FIX", True)
5✔
1547
        run = functools.partial(lint, prefix=prefix, fix=fix)
5✔
1548
    run(files, tool=tool, up=up, sim=sim, strict=strict, ty=ty, **kwargs)
5✔
1549

1550

1551
@cli.command(name="check")
5✔
1552
def only_check(
5✔
1553
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1554
    skip_mypy: bool = Option(False, "--skip-mypy"),
1555
    dry: bool = DryOption,
1556
    up: bool = Option(False, help="Whether ruff check with --extend-select=UP"),
1557
    sim: bool = Option(True, help="Whether ruff check with --extend-select=SIM"),
1558
    strict: bool = Option(False, help="Whether run mypy with --strict"),
1559
    ty: bool = Option(False, help="Whether use ty instead of mypy"),
1560
) -> None:
1561
    """Check code style without reformat"""
1562
    bandit = _ensure_bool(bandit)
5✔
1563
    up = _ensure_bool(up)
5✔
1564
    sim = _ensure_bool(sim)
5✔
1565
    skip_mypy = _ensure_bool(skip_mypy)
5✔
1566
    check(dry=dry, bandit=bandit, skip_mypy=skip_mypy, up=up, sim=sim, ty=ty)
5✔
1567

1568

1569
class Sync(DryRun):
5✔
1570
    def __init__(
5✔
1571
        self, filename: str, extras: str, save: bool, dry: bool = False
1572
    ) -> None:
1573
        self.filename = filename
5✔
1574
        self.extras = extras
5✔
1575
        self._save = save
5✔
1576
        super().__init__(dry=dry)
5✔
1577

1578
    def gen(self) -> str:
5✔
1579
        extras, save = self.extras, self._save
5✔
1580
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
1581
        filename = _quote_shell_arg(self.filename)
5✔
1582
        if not (tool := Project.get_manage_tool()):
5✔
1583
            if should_remove or not is_venv():
5✔
1584
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
1585
            return f"python -m pip install -r {filename}"
5✔
1586
        prefix = ""
5✔
1587
        if not is_venv():
5✔
1588
            prefix = f"{tool} run " + "--no-sync " * (tool == "uv")
5✔
1589
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
1590
        export_cmd = "uv export --no-hashes --all-extras --all-groups --frozen"
5✔
1591
        if tool in ("poetry", "pdm"):
5✔
1592
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
1593
            if tool == "poetry":
5✔
1594
                ensure_pip = ""
5✔
1595
                if not UpgradeDependencies.should_with_dev():
5✔
1596
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
1597
                if extras and isinstance(extras, str | list):
5✔
1598
                    export_cmd += f" --extras={_quote_shell_arg(str(extras))}"
5✔
1599
            elif check_call(prefix + "python -m pip --version"):
×
1600
                ensure_pip = ""
×
1601
        elif check_call(prefix + "python -m pip --version"):
5✔
1602
            ensure_pip = ""
5✔
1603
        install_cmd = (
5✔
1604
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
1605
        )
1606
        if should_remove and not save:
5✔
1607
            install_cmd += " && rm -f {0}"
5✔
1608
        return install_cmd.format(filename, prefix, export_cmd)
5✔
1609

1610

1611
@cli.command()
5✔
1612
def sync(
5✔
1613
    filename: str = "dev_requirements.txt",
1614
    extras: str = Option("", "--extras", "-E"),
1615
    save: bool = Option(
1616
        False, "--save", "-s", help="Whether save the requirement file"
1617
    ),
1618
    dry: bool = DryOption,
1619
) -> None:
1620
    """Export dependencies by poetry to a txt file then install by pip."""
1621
    Sync(filename, extras, save, dry=dry).run()
5✔
1622

1623

1624
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
1625
    for name in ("test.sh", "test.py"):
5✔
1626
        if (file := path / name).exists():
5✔
1627
            return file
5✔
1628
    return None
5✔
1629

1630

1631
def test(dry: bool, ignore_script: bool = False) -> None:
5✔
1632
    cwd = Path.cwd()
5✔
1633
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
1634
    script_dir = root / "scripts"
5✔
1635
    if not _ensure_bool(ignore_script) and (
5✔
1636
        test_script := _should_run_test_script(script_dir)
1637
    ):
1638
        cmd = _quote_shell_arg(test_script.relative_to(root).as_posix())
5✔
1639
        if test_script.suffix == ".py":
5✔
1640
            cmd = "python " + cmd
5✔
1641
        if cwd != root:
5✔
1642
            cmd = f"cd {_quote_shell_arg(root)} && " + cmd
5✔
1643
    else:
1644
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
1645
        if not is_venv() or not check_call("coverage --version"):
5✔
1646
            sep = " && "
5✔
1647
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
1648
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
1649
    exit_if_run_failed(cmd, dry=dry)
5✔
1650

1651

1652
@cli.command(name="test")
5✔
1653
def coverage_test(
5✔
1654
    dry: bool = DryOption,
1655
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1656
) -> None:
1657
    """Run unittest by pytest and report coverage"""
1658
    return test(dry, ignore_script)
5✔
1659

1660

1661
def _not_a_distribution() -> bool:
5✔
1662
    with contextlib.suppress(FileNotFoundError, KeyError, EnvError):
5✔
1663
        toml_text = Project.load_toml_text()
5✔
1664
        doc = tomllib.loads(toml_text)
5✔
1665
        match doc["build-system"]["build-backend"]:
5✔
1666
            case "pdm.backend":
5✔
1667
                return doc.get("tool", {}).get("pdm", {}).get("distribution") is False
5✔
1668
    return True
5✔
1669

1670

1671
class Publish:
5✔
1672
    class CommandEnum(StrEnum):
5✔
1673
        poetry = "poetry publish --build"
5✔
1674
        pdm = "pdm publish"
5✔
1675
        uv = "uv build && uv publish"
5✔
1676
        twine = "python -m build && twine upload"
5✔
1677

1678
    @classmethod
5✔
1679
    def gen(cls, tool: str | None, verbose: bool) -> str:
5✔
1680
        if tool == "auto":
5✔
1681
            tool = Project.get_manage_tool() or ""
5✔
1682
        if tool:
5✔
1683
            if tool == "uv":
5✔
1684
                envs = ["UV_PUBLISH_INDEX", "UV_PUBLISH_URL", "UV_PUBLISH_TOKEN"]
5✔
1685
                if not any(os.getenv(i) for i in envs):
5✔
1686
                    if _not_a_distribution():
5✔
1687
                        if verbose:
5✔
1688
                            yellow_warn(
×
1689
                                "Skip uv publish as project is not distribution"
1690
                            )
1691
                        return "fast version"
5✔
1692
                    if verbose:
5✔
1693
                        yellow_warn(f"Skip uv publish as envs ({envs}) not set")
×
1694
                    return "uv build"
5✔
1695
            elif tool == "pdm":
5✔
1696
                env = "PDM_PUBLISH_REPO"
×
1697
                if not os.getenv(env):
×
1698
                    if verbose:
×
1699
                        yellow_warn(f"Skip pdm publish as env ({env}) not set")
×
1700
                    return "pdm build"
×
1701
            return cls.CommandEnum[tool]
5✔
1702
        return cls.CommandEnum.twine
5✔
1703

1704

1705
@cli.command()
5✔
1706
def upload(
5✔
1707
    verbose: bool = False,
1708
    tool: str = ToolOption,
1709
    dry: bool = DryOption,
1710
) -> None:
1711
    """Shortcut for package publish"""
1712
    cmd = Publish.gen(_ensure_str(tool), verbose)
5✔
1713
    exit_if_run_failed(cmd, dry=dry)
5✔
1714

1715

1716
def should_use_just() -> bool:
5✔
1717
    if shutil.which("just") is None or load_bool("FASTDEVCLI_IGNORE_JUST_DEV"):
5✔
1718
        return False
5✔
1719
    d = Path.cwd()
5✔
1720
    for _ in range(5):
5✔
1721
        f = d / "justfile"
5✔
1722
        if f.exists():
5✔
1723
            return _prefer_just_dev(f)
5✔
1724
        if d.joinpath("pyproject.toml").exists():
×
1725
            break
×
1726
        d = d.parent
×
1727
    return False
×
1728

1729

1730
def _prefer_just_dev(f: Path) -> bool:
5✔
1731
    text = f.read_text(encoding="utf-8")
5✔
1732
    lines = text.splitlines()
5✔
1733
    dev_recipe = "dev *args:"
5✔
1734
    re_import = re.compile(r"import[?]? ")
5✔
1735
    has_import = False
5✔
1736
    total = len(lines)
5✔
1737
    for i, line in enumerate(lines):
5✔
1738
        if line.startswith(dev_recipe):
5✔
1739
            # Avoid cycle callback
1740
            command_lines = []
×
1741
            for j in range(i + 1, total):
×
1742
                try:
×
1743
                    s = lines[j]
×
1744
                except IndexError:
×
1745
                    break
×
1746
                if not s.startswith(" "):
×
1747
                    break
×
1748
                command_lines.append(s)
×
1749
            if not command_lines:  # Invalid justfile
×
1750
                return False
×
1751
            return all("fast dev" not in i for i in command_lines)
×
1752
        elif not has_import and re_import.match(line):
5✔
1753
            has_import = True
5✔
1754
    if has_import:
5✔
1755
        recipes = capture_cmd_output("just --list").splitlines()
5✔
1756
        for recipe in recipes:
5✔
1757
            recipe = recipe.strip()
5✔
1758
            if recipe.startswith(dev_recipe):
5✔
1759
                return "fast dev" not in recipe
×
1760
    return False
5✔
1761

1762

1763
def _load_fastapi_entrypoint() -> str:
5✔
1764
    with contextlib.suppress(FileNotFoundError, KeyError):
×
1765
        try:
×
1766
            toml_text = Project.load_toml_text()
×
1767
        except EnvError:
×
1768
            return ""
×
1769
        doc = tomllib.loads(toml_text)
×
1770
        return doc["tool"]["fastapi"]["entrypoint"]
×
1771
    return ""
×
1772

1773

1774
def _parse_serve_file(
5✔
1775
    uvicorn: bool | None, filename: str, cmd: str, args: list[str]
1776
) -> str:
1777
    if m := re.search(r"(.*):(\d+)$", filename):
5✔
1778
        h, p = m.group(1), m.group(2)
5✔
1779
        if h and "--host" not in str(args):
5✔
1780
            if h == "0":
5✔
1781
                args.append("--host=0.0.0.0")
5✔
1782
            else:
1783
                args.append(f"--host={h}")
5✔
1784
        args.append(f"--port={p}")
5✔
1785
        if uvicorn:
5✔
1786
            if entrypoint := _load_fastapi_entrypoint():
×
1787
                cmd += " " + entrypoint
×
1788
            else:
1789
                p = Path("main.py")
×
1790
                if p.exists():
×
1791
                    cmd += " main:app"
×
1792
                elif Path("app", p.name).exists():
×
1793
                    cmd += " app.main:app"
×
1794
                elif Path("app.py").exists():
×
1795
                    cmd += " app:app"
×
1796
        return cmd
5✔
1797
    if uvicorn and ((filepath := Path(filename)).is_file() or filepath.suffix == ".py"):
5✔
1798
        filename = filepath.stem + ":app"
5✔
1799
        parent_names = [j for i in filepath.parents if (j := i.name)]
5✔
1800
        if parent_names:
5✔
1801
            filename = ".".join([*parent_names[::-1], filename])
5✔
1802
    cmd += " " + _quote_shell_arg(filename)
5✔
1803
    return cmd
5✔
1804

1805

1806
def _runserver(
5✔
1807
    uvicorn: bool | None,
1808
    host: OptionInfo | str | None,
1809
    port: OptionInfo | int | None,
1810
    file: ArgumentInfo | str | None,
1811
) -> tuple[str, list[str]]:
1812
    cmd = "uvicorn" if uvicorn else "fastapi dev"
5✔
1813
    args = []
5✔
1814
    if (host := getattr(host, "default", host)) and host not in (
5✔
1815
        "localhost",
1816
        "127.0.0.1",
1817
    ):
1818
        args.append(f"--host={host}")
5✔
1819
    no_port_yet = True
5✔
1820
    if file is not None:
5✔
1821
        filename = str(file)
5✔
1822
        try:
5✔
1823
            port = int(filename)
5✔
1824
        except ValueError:
5✔
1825
            cmd = _parse_serve_file(uvicorn, filename, cmd, args)
5✔
1826
        else:
1827
            if port != 8000:
5✔
1828
                args.append(f"--port={port}")
5✔
1829
                no_port_yet = False
5✔
1830
    elif uvicorn and (entrypoint := _load_fastapi_entrypoint()):
5✔
1831
        cmd += " " + entrypoint
×
1832
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
1833
        args.append(f"--port={port}")
5✔
1834
    if shutil.which("pdm") is not None:
5✔
1835
        cmd = "pdm run " + cmd
5✔
1836
    return cmd, args
5✔
1837

1838

1839
def dev(
5✔
1840
    port: int | None | OptionInfo,
1841
    host: str | None | OptionInfo,
1842
    fastapi: bool | None = None,
1843
    uvicorn: bool | None = None,
1844
    prod: bool | None = None,
1845
    reload: bool | None = None,
1846
    just: bool | None = None,
1847
    file: str | None | ArgumentInfo = None,
1848
    dry: bool = False,
1849
) -> None:
1850
    if just is not False and should_use_just():
5✔
1851
        args = [i for i in sys.argv[2:] if i != "--dry"]
×
1852
        cmd = "just dev"
×
1853
    else:
1854
        cmd, args = _runserver(uvicorn, host, port, file)
5✔
1855
    if args:
5✔
1856
        cmd += " " + _join_shell_args(args)
5✔
1857
    exit_if_run_failed(cmd, dry=dry)
5✔
1858

1859

1860
@cli.command(name="dev")
5✔
1861
def runserver(
5✔
1862
    file_or_port: str | None = typer.Argument(default=None),
1863
    port: int | None = Option(None, "-p", "--port"),
1864
    host: str | None = Option(None, "-h", "--host"),
1865
    fastapi: bool | None = None,
1866
    uvicorn: bool | None = None,
1867
    prod: bool | None = None,
1868
    reload: bool | None = None,
1869
    just: bool | None = None,
1870
    dry: bool = DryOption,
1871
) -> None:
1872
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1873
    f = functools.partial(
5✔
1874
        dev, port, host, fastapi, uvicorn, prod, reload, just, dry=dry
1875
    )
1876
    if getattr(file_or_port, "default", file_or_port):
5✔
1877
        f(file=file_or_port)
5✔
1878
    else:
1879
        f()
5✔
1880

1881

1882
@cli.command(name="exec")
5✔
1883
def run_by_subprocess(cmd: str, dry: bool = DryOption) -> None:
5✔
1884
    """Run cmd by subprocess, auto set shell=True when cmd contains '|>'"""
1885
    try:
5✔
1886
        rc = run_and_echo(cmd, verbose=True, dry=_ensure_bool(dry))
5✔
1887
    except FileNotFoundError as e:
5✔
1888
        command = cmd.split()[0]
5✔
1889
        if e.filename == command or (
5✔
1890
            e.filename is None and "系统找不到指定的文件" in str(e)
1891
        ):
1892
            echo(f"Command not found: {command}")
5✔
1893
            raise Exit(1) from None
5✔
1894
        raise
×
1895
    else:
1896
        if rc:
5✔
1897
            raise Exit(rc)
5✔
1898

1899

1900
class MakeDeps(DryRun):
5✔
1901
    def __init__(
5✔
1902
        self,
1903
        tool: str,
1904
        prod: bool = False,
1905
        dry: bool = False,
1906
        active: bool = False,
1907
        inexact: bool = False,
1908
        no_dev: bool = False,
1909
        verbose: bool = False,
1910
        frozen: bool = False,
1911
        no_extra: list[str] | None = None,
1912
        no_group: list[str] | None = None,
1913
    ) -> None:
1914
        self._tool = tool
5✔
1915
        self._prod = prod
5✔
1916
        self._active = active or load_bool("FASTDEVCLI_DEPS_ACTIVE")
5✔
1917
        self._inexact = inexact or load_bool("FASTDEVCLI_DEPS_INEXACT")
5✔
1918
        self._verbose = verbose
5✔
1919
        self._frozen = frozen
5✔
1920
        self._no_dev = no_dev
5✔
1921
        self._no_extra = no_extra
5✔
1922
        self._no_group = no_group
5✔
1923
        super().__init__(dry=dry)
5✔
1924

1925
    def should_ensure_pip(self) -> bool:
5✔
1926
        return True
5✔
1927

1928
    def should_upgrade_pip(self) -> bool:
5✔
1929
        return True
5✔
1930

1931
    def get_groups(self) -> list[str]:
5✔
1932
        if self._prod:
5✔
1933
            return []
5✔
1934
        return ["dev"]
5✔
1935

1936
    def gen(self) -> str:
5✔
1937
        cmd = self._gen()
5✔
1938
        if self._verbose:
5✔
1939
            cmd += " --verbose"
×
1940
        if self._no_dev:
5✔
1941
            opt = " --no-dev"
×
1942
            if opt not in cmd:
×
1943
                cmd += opt
×
1944
        if self._no_extra:
5✔
1945
            cmd += " " + " ".join(
5✔
1946
                f"--no-extra {_quote_shell_arg(i)}" for i in self._no_extra
1947
            )
1948
        if self._no_group:
5✔
1949
            cmd += " " + " ".join(
5✔
1950
                f"--no-group {_quote_shell_arg(i)}" for i in self._no_group
1951
            )
1952
        if self._frozen:
5✔
1953
            cmd += " --frozen"
×
1954
        if opts := os.getenv("FASTDEVCLI_DEPS_OPTS"):
5✔
1955
            cmd += " " + opts.strip()
×
1956
        return cmd
5✔
1957

1958
    def get_package_name(self) -> str:
5✔
1959
        with contextlib.suppress(FileNotFoundError, KeyError):
5✔
1960
            try:
5✔
1961
                toml_text = Project.load_toml_text()
5✔
1962
            except EnvError:
×
1963
                return ""
×
1964
            doc = tomllib.loads(toml_text)
5✔
1965
            tool_section = doc["tool"]
5✔
1966
            uv_package = tool_section.get("uv", {}).get("package")
5✔
1967
            if uv_package is not None:
5✔
1968
                if not uv_package:
×
1969
                    return ""
×
1970
            else:
1971
                match doc["build-system"]["build-backend"]:
5✔
1972
                    case "pdm.backend":
5✔
1973
                        if not tool_section.get("pdm", {}).get("distribution", True):
5✔
1974
                            return ""
×
1975
                    case x if x.startswith("poetry"):
×
1976
                        if not tool_section.get("poetry", {}).get("package-mode", True):
×
1977
                            return ""
×
1978
            return cast(str, doc["project"]["name"])
5✔
1979
        return ""
×
1980

1981
    def _gen(self) -> str:
5✔
1982
        if self._tool == "pdm":
5✔
1983
            return "pdm install --frozen " + ("--prod" if self._prod else "-G :all")
5✔
1984
        elif self._tool == "uv":
5✔
1985
            uv_sync = "uv sync"
5✔
1986
            if project := self.get_package_name():
5✔
1987
                uv_sync += " " + _quote_shell_arg(f"--reinstall-package={project}")
5✔
1988
            uv_sync += " --inexact" * self._inexact + " --active" * self._active
5✔
1989
            return uv_sync + (
5✔
1990
                " --no-dev" if self._prod else " --all-extras --all-groups"
1991
            )
1992
        elif self._tool == "poetry":
5✔
1993
            return "poetry install " + (
5✔
1994
                "--only=main" if self._prod else "--all-extras --all-groups"
1995
            )
1996
        else:
1997
            cmd = "python -m pip install -e ."
5✔
1998
            if gs := self.get_groups():
5✔
1999
                cmd += " " + " ".join(f"--group {g}" for g in gs)
5✔
2000
            upgrade = "python -m pip install --upgrade pip"
5✔
2001
            if self.should_ensure_pip():
5✔
2002
                cmd = f"python -m ensurepip && {upgrade} && {cmd}"
5✔
2003
            elif self.should_upgrade_pip():
5✔
2004
                cmd = f"{upgrade} && {cmd}"
5✔
2005
            return cmd
5✔
2006

2007

2008
@cli.command(name="deps")
5✔
2009
def make_deps(
5✔
2010
    prod: bool = Option(
2011
        False,
2012
        "--prod",
2013
        help="Only instead production dependencies.",
2014
    ),
2015
    tool: str = ToolOption,
2016
    use_uv: bool = Option(False, "--uv", help="Use `uv` to install deps"),
2017
    use_pdm: bool = Option(False, "--pdm", help="Use `pdm` to install deps"),
2018
    use_pip: bool = Option(False, "--pip", help="Use `pip` to install deps"),
2019
    use_poetry: bool = Option(False, "--poetry", help="Use `poetry` to install deps"),
2020
    active: bool = Option(
2021
        False, help="Add `--active` to uv sync command(Only work for uv project)"
2022
    ),
2023
    inexact: bool = Option(
2024
        False, help="Add `--inexact` to uv sync command(Only work for uv project)"
2025
    ),
2026
    no_dev: bool = Option(False, "--no-dev"),
2027
    no_extra: Annotated[list[str] | None, Option()] = None,
2028
    no_group: Annotated[list[str] | None, Option()] = None,
2029
    frozen: bool = Option(False, "--frozen", "--frozen-lockfile", "--no-lock"),
2030
    verbose: bool = Option(False, "--verbose"),
2031
    dry: bool = DryOption,
2032
) -> None:
2033
    """Run: ruff check/format to reformat code and then mypy to check"""
2034
    if use_uv + use_pdm + use_pip + use_poetry > 1:
×
2035
        raise typer.BadParameter(
×
2036
            "can only choose one",
2037
            param_hint=("--uv", "--pdm", "--pip", "--poetry"),
2038
        )
2039
    if use_uv:
×
2040
        tool = "uv"
×
2041
    elif use_pdm:
×
2042
        tool = "pdm"
×
2043
    elif use_pip:
×
2044
        tool = "pip"
×
2045
    elif use_poetry:
×
2046
        tool = "poetry"
×
2047
    elif tool == ToolOption.default:
×
2048
        tool = Project.get_manage_tool(cache=True) or "pip"
×
2049
    bool_opts = {
×
2050
        "active": active,
2051
        "inexact": inexact,
2052
        "no_dev": no_dev,
2053
        "verbose": verbose,
2054
        "frozen": frozen,
2055
        "dry": dry,
2056
    }
2057
    MakeDeps(tool, prod, no_extra=no_extra, no_group=no_group, **bool_opts).run()
×
2058

2059

2060
class UvPypi(DryRun):
5✔
2061
    PYPI = "https://pypi.org/simple"
5✔
2062
    HOST = "https://files.pythonhosted.org"
5✔
2063

2064
    def __init__(
5✔
2065
        self,
2066
        lock_file: Path,
2067
        dry: bool,
2068
        verbose: bool,
2069
        quiet: bool,
2070
        slim: bool = False,
2071
        reverse: bool = False,
2072
    ) -> None:
2073
        super().__init__(dry=dry)
5✔
2074
        self.lock_file = lock_file
5✔
2075
        self._verbose = _ensure_bool(verbose)
5✔
2076
        self._quiet = _ensure_bool(quiet)
5✔
2077
        self._slim = _ensure_bool(slim)
5✔
2078
        self._reverse = _ensure_bool(reverse)
5✔
2079

2080
    def run(self) -> None:
5✔
2081
        try:
5✔
2082
            rc = self.update_lock(
5✔
2083
                self.lock_file, self._verbose, self._quiet, self._slim, self._reverse
2084
            )
2085
        except ValueError as e:
×
2086
            secho(str(e), fg=typer.colors.RED)
×
2087
            raise Exit(1) from e
×
2088
        else:
2089
            if rc != 0:
5✔
2090
                raise Exit(rc)
5✔
2091

2092
    @staticmethod
5✔
2093
    def get_target_content(
5✔
2094
        text: str, verbose: bool, target_registry: str, target_host: str
2095
    ) -> str | None:
2096
        registry_pattern = r'(registry = ")(.*?)"'
5✔
2097
        registry_urls = {i[1] for i in re.findall(registry_pattern, text)}
5✔
2098
        download_pattern = r'(url = ")(https?://.*?)(/packages/.*?\.)(gz|whl|zip)"'
5✔
2099
        download_hosts = {i[1] for i in re.findall(download_pattern, text)}
5✔
2100
        if not registry_urls:
5✔
2101
            raise ValueError(
×
2102
                f"Failed to find pattern {registry_pattern!r} in uv lock file"
2103
            )
2104

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

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

2111
        if len(registry_urls) == 1:
5✔
2112
            current_registry = registry_urls.pop()
5✔
2113
            if current_registry == target_registry:
5✔
2114
                if download_hosts == {target_host}:
5✔
2115
                    return None
5✔
2116
            else:
2117
                text = replace_registry(text)
5✔
2118
                if verbose:
5✔
2119
                    echo(f"{current_registry} --> {target_registry}")
5✔
2120
        else:
2121
            # TODO: ask each one to confirm replace
2122
            text = replace_registry(text)
×
2123
            if verbose:
×
2124
                for current_registry in sorted(registry_urls):
×
2125
                    echo(f"{current_registry} --> {target_registry}")
×
2126
        if len(download_hosts) == 1:
5✔
2127
            current_host = download_hosts.pop()
5✔
2128
            if current_host != target_host:
5✔
2129
                text = replace_host(text)
5✔
2130
                if verbose:
5✔
2131
                    echo(f"{current_host} --> {target_host}")
5✔
2132
        elif download_hosts:
×
2133
            # TODO: ask each one to confirm replace
2134
            text = replace_host(text)
×
2135
            if verbose:
×
2136
                for current_host in sorted(download_hosts):
×
2137
                    echo(f"{current_host} --> {target_host}")
×
2138
        return text
5✔
2139

2140
    @classmethod
5✔
2141
    def update_lock(
5✔
2142
        cls,
2143
        p: Path,
2144
        verbose: bool,
2145
        quiet: bool,
2146
        slim: bool = False,
2147
        reverse: bool = False,
2148
    ) -> int:
2149
        text = p.read_text("utf-8")
5✔
2150
        target_register, target_host = cls.PYPI, cls.HOST
5✔
2151
        if reverse:
5✔
2152
            try:
5✔
2153
                target_register, target_host = cls.get_register_from_uv_config()
5✔
2154
            except FileNotFoundError:
5✔
2155
                if verbose:
5✔
2156
                    echo("Skip register reverse as global uv config file not found.")
5✔
2157
                if quiet:
5✔
2158
                    return 0
5✔
2159
                return 1
×
2160
        new_text = cls.get_target_content(text, verbose, target_register, target_host)
5✔
2161
        if new_text is None:
5✔
2162
            if verbose:
5✔
2163
                echo(f"Registry of {p} is {target_register}, no need to change.")
5✔
2164
            return 0
5✔
2165
        return cls.slim_and_write(new_text, slim, p, verbose, quiet)
5✔
2166

2167
    @classmethod
5✔
2168
    def get_register_from_uv_config(cls) -> tuple[str, str]:
5✔
2169
        config_file = cls.get_uv_config_file()
5✔
2170
        text = config_file.read_text("utf-8")
5✔
2171
        doc = tomllib.loads(text)
5✔
2172
        index_url = doc["index"][0]["url"]
5✔
2173
        return index_url, index_url.replace("/simple", "").rstrip("/")
5✔
2174

2175
    @staticmethod
5✔
2176
    def get_uv_config_file() -> Path:
5✔
2177
        config_dir = "AppData/Roaming" if is_windows() else ".config"
5✔
2178
        return Path.home() / config_dir / "uv/uv.toml"
5✔
2179

2180
    @staticmethod
5✔
2181
    def slim_and_write(
5✔
2182
        text: str, slim: bool, p: Path, verbose: bool, quiet: bool
2183
    ) -> int:
2184
        if slim:
5✔
2185
            pattern = r', size = \d+, upload-time = ".*?"'
5✔
2186
            text = re.sub(pattern, "", text)
5✔
2187
        size = p.write_text(text, encoding="utf-8")
5✔
2188
        if verbose:
5✔
2189
            echo(f"Updated {p} with {size} bytes.")
5✔
2190
        if quiet:
5✔
2191
            return 0
5✔
2192
        return 1
5✔
2193

2194

2195
@cli.command()
5✔
2196
def pypi(
5✔
2197
    file: str | None = typer.Argument(default=None),
2198
    dry: bool = DryOption,
2199
    verbose: bool = False,
2200
    quiet: bool = False,
2201
    slim: bool = False,
2202
    reverse: bool = False,
2203
) -> None:
2204
    """Change registry of uv.lock to be pypi.org"""
2205
    if not (p := Path(_ensure_str(file) or "uv.lock")).exists() and not (
5✔
2206
        (p := Project.get_work_dir() / p.name).exists()
2207
    ):
2208
        yellow_warn(f"{p.name!r} not found!")
5✔
2209
        return
5✔
2210
    UvPypi(p, dry, verbose, quiet, slim, reverse).run()
5✔
2211

2212

2213
def version_callback(value: bool) -> None:
5✔
2214
    if value:
5✔
2215
        echo("Fast Dev Cli Version: " + typer.style(__version__, bold=True))
5✔
2216
        raise Exit()
5✔
2217

2218

2219
@cli.callback()
2220
def common(
2221
    version: bool = Option(
2222
        None,
2223
        "--version",
2224
        "-V",
2225
        callback=version_callback,
2226
        is_eager=True,
2227
        help="Show the version of this tool",
2228
    ),
2229
) -> None: ...
2230

2231

2232
def main() -> None:
5✔
2233
    cli()
5✔
2234

2235

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