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

waketzheng / fast-dev-cli / 17695559492

13 Sep 2025 09:39AM UTC coverage: 90.427% (+0.05%) from 90.377%
17695559492

push

github

waketzheng
docs: update changelog

869 of 961 relevant lines covered (90.43%)

5.42 hits per line

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

90.42
/fast_dev_cli/cli.py
1
from __future__ import annotations
6✔
2

3
import contextlib
6✔
4
import functools
6✔
5
import importlib.metadata as importlib_metadata
6✔
6
import os
6✔
7
import re
6✔
8
import shlex
6✔
9
import shutil
6✔
10
import subprocess  # nosec:B404
6✔
11
import sys
6✔
12
from functools import cached_property
6✔
13
from pathlib import Path
6✔
14
from typing import (
6✔
15
    TYPE_CHECKING,
16
    Any,
17
    Literal,
18
    # Optional is required by Option generated by typer
19
    Optional,
20
    cast,
21
    get_args,
22
    overload,
23
)
24

25
import typer
6✔
26
from click import UsageError
6✔
27
from typer import Exit, Option, echo, secho
6✔
28
from typer.models import ArgumentInfo, OptionInfo
6✔
29

30
try:
6✔
31
    from . import __version__
6✔
32
except ImportError:  # pragma: no cover
33
    from importlib import import_module as _import  # For local unittest
34

35
    __version__ = _import(Path(__file__).parent.name).__version__
36

37
if sys.version_info >= (3, 11):  # pragma: no cover
38
    from enum import StrEnum
39

40
    import tomllib
41
else:  # pragma: no cover
42
    from enum import Enum
43

44
    import tomli as tomllib
45

46
    class StrEnum(str, Enum):
47
        __str__ = str.__str__
48

49

50
if TYPE_CHECKING:
51
    if sys.version_info >= (3, 11):
52
        from typing import Self
53
    else:
54
        from typing_extensions import Self
55

56
cli = typer.Typer(no_args_is_help=True)
6✔
57
DryOption = Option(False, "--dry", help="Only print, not really run shell command")
6✔
58
TOML_FILE = "pyproject.toml"
6✔
59
ToolName = Literal["poetry", "pdm", "uv"]
6✔
60
ToolOption = Option(
6✔
61
    "auto", "--tool", help="Explicit declare manage tool (default to auto detect)"
62
)
63

64

65
class FastDevCliError(Exception):
6✔
66
    """Basic exception of this library, all custom exceptions inherit from it"""
67

68

69
class ShellCommandError(FastDevCliError):
6✔
70
    """Raise if cmd command returncode is not zero"""
71

72

73
class ParseError(FastDevCliError):
6✔
74
    """Raise this if parse dependence line error"""
75

76

77
class EnvError(FastDevCliError):
6✔
78
    """Raise when expected to be managed by poetry, but toml file not found."""
79

80

81
def poetry_module_name(name: str) -> str:
6✔
82
    """Get module name that generated by `poetry new`"""
83
    try:
6✔
84
        from packaging.utils import canonicalize_name
6✔
85
    except ImportError:
×
86

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

90
    return canonicalize_name(name).replace("-", "_").replace(" ", "_")
6✔
91

92

93
def is_emoji(char: str) -> bool:
6✔
94
    try:
6✔
95
        import emoji
6✔
96
    except ImportError:
×
97
        pass
×
98
    else:
99
        return emoji.is_emoji(char)
6✔
100
    if re.match(r"[\w\d\s]", char):
×
101
        return False
×
102
    return not "\u4e00" <= char <= "\u9fff"  # Chinese character
×
103

104

105
def yellow_warn(msg: str) -> None:
6✔
106
    secho(msg, fg="yellow")
6✔
107

108

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

119

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

126

127
def _run_shell(cmd: list[str] | str, **kw: Any) -> subprocess.CompletedProcess[str]:
6✔
128
    if isinstance(cmd, str):
6✔
129
        kw.setdefault("shell", True)
6✔
130
    return subprocess.run(cmd, **kw)  # nosec:B603
6✔
131

132

133
def run_and_echo(
6✔
134
    cmd: str, *, dry: bool = False, verbose: bool = True, **kw: Any
135
) -> int:
136
    """Run shell command with subprocess and print it"""
137
    if verbose:
6✔
138
        echo(f"--> {cmd}")
6✔
139
    if dry:
6✔
140
        return 0
6✔
141
    command: list[str] | str = cmd
6✔
142
    if "shell" not in kw and not (set(cmd) & {"|", ">", "&"}):
6✔
143
        command = shlex.split(cmd)
6✔
144
    return _run_shell(command, **kw).returncode
6✔
145

146

147
def check_call(cmd: str) -> bool:
6✔
148
    r = _run_shell(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
6✔
149
    return r.returncode == 0
6✔
150

151

152
def capture_cmd_output(
6✔
153
    command: list[str] | str, *, raises: bool = False, **kw: Any
154
) -> str:
155
    if isinstance(command, str) and not kw.get("shell"):
6✔
156
        command = shlex.split(command)
6✔
157
    r = _run_shell(command, capture_output=True, encoding="utf-8", **kw)
6✔
158
    if raises and r.returncode != 0:
6✔
159
        raise ShellCommandError(r.stderr)
6✔
160
    return r.stdout.strip() or r.stderr
6✔
161

162

163
def _parse_version(line: str, pattern: re.Pattern[str]) -> str:
6✔
164
    return pattern.sub("", line).split("#")[0].strip(" '\"")
6✔
165

166

167
def read_version_from_file(
6✔
168
    package_name: str, work_dir: Path | None = None, toml_text: str | None = None
169
) -> str:
170
    if not package_name and toml_text:
6✔
171
        pattern = re.compile(r"version\s*=")
6✔
172
        for line in toml_text.splitlines():
6✔
173
            if pattern.match(line):
6✔
174
                return _parse_version(line, pattern)
6✔
175
    version_file = BumpUp.parse_filename(toml_text, work_dir, package_name)
6✔
176
    if version_file == TOML_FILE:
6✔
177
        if toml_text is None:
6✔
178
            toml_text = Project.load_toml_text()
6✔
179
        context = tomllib.loads(toml_text)
6✔
180
        with contextlib.suppress(KeyError):
6✔
181
            return cast(str, context["project"]["version"])
6✔
182
        with contextlib.suppress(KeyError):  # Poetry V1
6✔
183
            return cast(str, context["tool"]["poetry"]["version"])
6✔
184
        secho(f"WARNING: can not find 'version' item in {version_file}!")
6✔
185
        return "0.0.0"
6✔
186
    pattern = re.compile(r"__version__\s*=")
6✔
187
    for line in Path(version_file).read_text("utf-8").splitlines():
6✔
188
        if pattern.match(line):
6✔
189
            return _parse_version(line, pattern)
6✔
190
    # TODO: remove or refactor the following lines.
191
    if work_dir is None:
×
192
        work_dir = Project.get_work_dir()
×
193
    package_dir = work_dir / package_name
×
194
    if (
×
195
        not (init_file := package_dir / "__init__.py").exists()
196
        and not (init_file := work_dir / "src" / package_name / init_file.name).exists()
197
        and not (init_file := work_dir / "app" / init_file.name).exists()
198
    ):
199
        secho("WARNING: __init__.py file does not exist!")
×
200
        return "0.0.0"
×
201

202
    pattern = re.compile(r"__version__\s*=")
×
203
    for line in init_file.read_text("utf-8").splitlines():
×
204
        if pattern.match(line):
×
205
            return _parse_version(line, pattern)
×
206
    secho(f"WARNING: can not find '__version__' var in {init_file}!")
×
207
    return "0.0.0"
×
208

209

210
@overload
211
def get_current_version(
212
    verbose: bool = False,
213
    is_poetry: bool | None = None,
214
    package_name: str | None = None,
215
    *,
216
    check_version: Literal[False] = False,
217
) -> str: ...
218

219

220
@overload
221
def get_current_version(
222
    verbose: bool = False,
223
    is_poetry: bool | None = None,
224
    package_name: str | None = None,
225
    *,
226
    check_version: Literal[True] = True,
227
) -> tuple[bool, str]: ...
228

229

230
def get_current_version(
6✔
231
    verbose: bool = False,
232
    is_poetry: bool | None = None,
233
    package_name: str | None = None,
234
    *,
235
    check_version: bool = False,
236
) -> str | tuple[bool, str]:
237
    if is_poetry is True or Project.manage_by_poetry():
6✔
238
        cmd = ["poetry", "version", "-s"]
6✔
239
        if verbose:
6✔
240
            echo(f"--> {' '.join(cmd)}")
6✔
241
        if out := capture_cmd_output(cmd, raises=True):
6✔
242
            out = out.splitlines()[-1].strip().split()[-1]
6✔
243
        if check_version:
6✔
244
            return True, out
6✔
245
        return out
6✔
246
    toml_text = work_dir = None
6✔
247
    if package_name is None:
6✔
248
        work_dir = Project.get_work_dir()
6✔
249
        toml_text = Project.load_toml_text()
6✔
250
        doc = tomllib.loads(toml_text)
6✔
251
        project_name = doc.get("project", {}).get("name", work_dir.name)
6✔
252
        package_name = re.sub(r"[- ]", "_", project_name)
6✔
253
    local_version = read_version_from_file(package_name, work_dir, toml_text)
6✔
254
    try:
6✔
255
        installed_version = importlib_metadata.version(package_name)
6✔
256
    except importlib_metadata.PackageNotFoundError:
6✔
257
        installed_version = ""
6✔
258
    current_version = local_version or installed_version
6✔
259
    if not current_version:
6✔
260
        raise FastDevCliError(f"Failed to get current version of {package_name!r}")
×
261
    if check_version:
6✔
262
        is_conflict = bool(local_version) and local_version != installed_version
6✔
263
        return is_conflict, current_version
6✔
264
    return current_version
6✔
265

266

267
def _ensure_bool(value: bool | OptionInfo) -> bool:
6✔
268
    if not isinstance(value, bool):
6✔
269
        value = getattr(value, "default", False)
6✔
270
    return value
6✔
271

272

273
def _ensure_str(value: str | OptionInfo | None) -> str:
6✔
274
    if not isinstance(value, str):
6✔
275
        value = getattr(value, "default", "")
6✔
276
    return value
6✔
277

278

279
def exit_if_run_failed(
6✔
280
    cmd: str,
281
    env: dict[str, str] | None = None,
282
    _exit: bool = False,
283
    dry: bool = False,
284
    **kw: Any,
285
) -> subprocess.CompletedProcess[str]:
286
    run_and_echo(cmd, dry=True)
6✔
287
    if _ensure_bool(dry):
6✔
288
        return subprocess.CompletedProcess("", 0)
6✔
289
    if env is not None:
6✔
290
        env = {**os.environ, **env}
6✔
291
    r = _run_shell(cmd, env=env, **kw)
6✔
292
    if rc := r.returncode:
6✔
293
        if _exit:
6✔
294
            sys.exit(rc)
6✔
295
        raise Exit(rc)
6✔
296
    return r
6✔
297

298

299
class DryRun:
6✔
300
    def __init__(self, _exit: bool = False, dry: bool = False) -> None:
6✔
301
        self.dry = _ensure_bool(dry)
6✔
302
        self._exit = _exit
6✔
303

304
    def gen(self) -> str:
6✔
305
        raise NotImplementedError
6✔
306

307
    def run(self) -> None:
6✔
308
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
6✔
309

310

311
class BumpUp(DryRun):
6✔
312
    class PartChoices(StrEnum):
6✔
313
        patch = "patch"
6✔
314
        minor = "minor"
6✔
315
        major = "major"
6✔
316

317
    def __init__(
6✔
318
        self,
319
        commit: bool,
320
        part: str,
321
        filename: str | None = None,
322
        dry: bool = False,
323
        no_sync: bool = False,
324
        emoji: bool | None = None,
325
    ) -> None:
326
        self.commit = commit
6✔
327
        self.part = part
6✔
328
        if filename is None:
6✔
329
            filename = self.parse_filename()
6✔
330
        self.filename = filename
6✔
331
        self._no_sync = no_sync
6✔
332
        self._emoji = emoji
6✔
333
        super().__init__(dry=dry)
6✔
334

335
    @staticmethod
6✔
336
    def get_last_commit_message(raises: bool = False) -> str:
6✔
337
        cmd = 'git show --pretty=format:"%s" -s HEAD'
6✔
338
        return capture_cmd_output(cmd, raises=raises)
6✔
339

340
    @classmethod
6✔
341
    def should_add_emoji(cls) -> bool:
6✔
342
        """
343
        If last commit message is startswith emoji,
344
        add a ⬆️ flag at the prefix of bump up commit message.
345
        """
346
        try:
6✔
347
            first_char = cls.get_last_commit_message(raises=True)[0]
6✔
348
        except (IndexError, ShellCommandError):
6✔
349
            return False
6✔
350
        else:
351
            return is_emoji(first_char)
6✔
352

353
    @staticmethod
6✔
354
    def parse_filename(
6✔
355
        toml_text: str | None = None,
356
        work_dir: Path | None = None,
357
        package_name: str | None = None,
358
    ) -> str:
359
        if toml_text is None:
6✔
360
            toml_text = Project.load_toml_text()
6✔
361
        context = tomllib.loads(toml_text)
6✔
362
        by_version_plugin = False
6✔
363
        try:
6✔
364
            ver = context["project"]["version"]
6✔
365
        except KeyError:
6✔
366
            pass
6✔
367
        else:
368
            if isinstance(ver, str):
6✔
369
                if ver in ("0", "0.0.0"):
6✔
370
                    by_version_plugin = True
6✔
371
                elif re.match(r"\d+\.\d+\.\d+", ver):
6✔
372
                    return TOML_FILE
6✔
373
        if not by_version_plugin:
6✔
374
            try:
6✔
375
                version_value = context["tool"]["poetry"]["version"]
6✔
376
            except KeyError:
6✔
377
                if not Project.manage_by_poetry():
6✔
378
                    if work_dir is None:
6✔
379
                        work_dir = Project.get_work_dir()
6✔
380
                    for tool in ("pdm", "hatch"):
6✔
381
                        with contextlib.suppress(KeyError):
6✔
382
                            version_path = cast(
6✔
383
                                str, context["tool"][tool]["version"]["path"]
384
                            )
385
                            if (
6✔
386
                                Path(version_path).exists()
387
                                or work_dir.joinpath(version_path).exists()
388
                            ):
389
                                return version_path
6✔
390
                    # version = { source = "file", path = "fast_dev_cli/__init__.py" }
391
                    v_key = "version = "
6✔
392
                    p_key = 'path = "'
6✔
393
                    for line in toml_text.splitlines():
6✔
394
                        if not line.startswith(v_key):
×
395
                            continue
×
396
                        if p_key in (value := line.split(v_key, 1)[-1].split("#")[0]):
×
397
                            filename = value.split(p_key, 1)[-1].split('"')[0]
×
398
                            if work_dir.joinpath(filename).exists():
×
399
                                return filename
×
400
            else:
401
                by_version_plugin = version_value in ("0", "0.0.0", "init")
6✔
402
        if by_version_plugin:
6✔
403
            try:
6✔
404
                package_item = context["tool"]["poetry"]["packages"]
6✔
405
            except KeyError:
6✔
406
                try:
6✔
407
                    project_name = context["project"]["name"]
6✔
408
                except KeyError:
6✔
409
                    packages = []
6✔
410
                else:
411
                    packages = [(poetry_module_name(project_name), "")]
×
412
            else:
413
                packages = [
6✔
414
                    (j, i.get("from", ""))
415
                    for i in package_item
416
                    if (j := i.get("include"))
417
                ]
418
            # In case of managed by `poetry-plugin-version`
419
            cwd = Path.cwd()
6✔
420
            pattern = re.compile(r"__version__\s*=\s*['\"]")
6✔
421
            ds: list[Path] = []
6✔
422
            if package_name is not None:
6✔
423
                packages.insert(0, (package_name, ""))
×
424
            for package_name, source_dir in packages:
6✔
425
                ds.append(cwd / package_name)
6✔
426
                ds.append(cwd / "src" / package_name)
6✔
427
                if source_dir and source_dir != "src":
6✔
428
                    ds.append(cwd / source_dir / package_name)
6✔
429
            module_name = poetry_module_name(cwd.name)
6✔
430
            ds.extend([cwd / module_name, cwd / "src" / module_name, cwd])
6✔
431
            for d in ds:
6✔
432
                init_file = d / "__init__.py"
6✔
433
                if (
6✔
434
                    init_file.exists() and pattern.search(init_file.read_text("utf8"))
435
                ) or (
436
                    (init_file := init_file.with_name("__version__.py")).exists()
437
                    and pattern.search(init_file.read_text("utf8"))
438
                ):
439
                    break
6✔
440
            else:
441
                raise ParseError("Version file not found! Where are you now?")
6✔
442
            return os.path.relpath(init_file, cwd)
6✔
443

444
        return TOML_FILE
6✔
445

446
    def get_part(self, s: str) -> str:
6✔
447
        choices: dict[str, str] = {}
6✔
448
        for i, p in enumerate(self.PartChoices, 1):
6✔
449
            v = str(p)
6✔
450
            choices.update({str(i): v, v: v})
6✔
451
        try:
6✔
452
            return choices[s]
6✔
453
        except KeyError as e:
6✔
454
            echo(f"Invalid part: {s!r}")
6✔
455
            raise Exit(1) from e
6✔
456

457
    def gen(self) -> str:
6✔
458
        should_sync, _version = get_current_version(check_version=True)
6✔
459
        filename = self.filename
6✔
460
        echo(f"Current version(@{filename}): {_version}")
6✔
461
        if self.part:
6✔
462
            part = self.get_part(self.part)
6✔
463
        else:
464
            part = "patch"
6✔
465
            if a := input("Which one?").strip():
6✔
466
                part = self.get_part(a)
6✔
467
        self.part = part
6✔
468
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
6✔
469
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
6✔
470
        if self.commit:
6✔
471
            if part != "patch":
6✔
472
                cmd += " --tag"
6✔
473
            cmd += " --commit"
6✔
474
            if self._emoji or (self._emoji is None and self.should_add_emoji()):
6✔
475
                cmd += " --message-emoji=1"
6✔
476
            if not load_bool("DONT_GIT_PUSH"):
6✔
477
                cmd += " && git push && git push --tags && git log -1"
6✔
478
        else:
479
            cmd += " --allow-dirty"
6✔
480
        if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
6✔
481
            cmd = f"{sync} && " + cmd
6✔
482
        return cmd
6✔
483

484
    def run(self) -> None:
6✔
485
        super().run()
6✔
486
        if not self.commit and not self.dry:
6✔
487
            new_version = get_current_version(True)
6✔
488
            echo(new_version)
6✔
489
            if self.part != "patch":
6✔
490
                echo("You may want to pin tag by `fast tag`")
6✔
491

492

493
@cli.command()
6✔
494
def version() -> None:
6✔
495
    """Show the version of this tool"""
496
    echo("Fast Dev Cli Version: " + typer.style(__version__, fg=typer.colors.BLUE))
6✔
497
    with contextlib.suppress(FileNotFoundError, KeyError):
6✔
498
        toml_text = Project.load_toml_text()
6✔
499
        doc = tomllib.loads(toml_text)
6✔
500
        if value := doc.get("project", {}).get("version", ""):
6✔
501
            styled = typer.style(value, bold=True, fg=typer.colors.CYAN)
×
502
            if project_name := doc["project"].get("name", ""):
×
503
                echo(f"{project_name} version: " + styled)
×
504
            else:
505
                echo(f"Got Version from {TOML_FILE}: " + styled)
×
506
            return
×
507
        version_file = doc["tool"]["pdm"]["version"]["path"]
6✔
508
        text = Project.get_work_dir().joinpath(version_file).read_text()
6✔
509
        varname = "__version__"
6✔
510
        for line in text.splitlines():
6✔
511
            if line.strip().startswith(varname):
6✔
512
                value = line.split("=", 1)[-1].strip().strip('"').strip("'")
6✔
513
                styled = typer.style(value, bold=True)
6✔
514
                echo(f"Version value in {version_file}: " + styled)
6✔
515
                break
6✔
516

517

518
@cli.command(name="bump")
6✔
519
def bump_version(
6✔
520
    part: BumpUp.PartChoices,
521
    commit: bool = Option(
522
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
523
    ),
524
    emoji: Optional[bool] = Option(
525
        None, "--emoji", help="Whether add emoji prefix to commit message"
526
    ),
527
    no_sync: bool = Option(
528
        False, "--no-sync", help="Do not run sync command to update version"
529
    ),
530
    dry: bool = DryOption,
531
) -> None:
532
    """Bump up version string in pyproject.toml"""
533
    if emoji is not None:
6✔
534
        emoji = _ensure_bool(emoji)
6✔
535
    return BumpUp(
6✔
536
        _ensure_bool(commit),
537
        getattr(part, "value", part),
538
        no_sync=_ensure_bool(no_sync),
539
        emoji=emoji,
540
        dry=dry,
541
    ).run()
542

543

544
def bump() -> None:
6✔
545
    part, commit = "", False
6✔
546
    if args := sys.argv[2:]:
6✔
547
        if "-c" in args or "--commit" in args:
6✔
548
            commit = True
6✔
549
        for a in args:
6✔
550
            if not a.startswith("-"):
6✔
551
                part = a
6✔
552
                break
6✔
553
    return BumpUp(commit, part, no_sync="--no-sync" in args, dry="--dry" in args).run()
6✔
554

555

556
class Project:
6✔
557
    path_depth = 5
6✔
558
    _tool: ToolName | None = None
6✔
559

560
    @staticmethod
6✔
561
    def is_poetry_v2(text: str) -> bool:
6✔
562
        return 'build-backend = "poetry' in text
6✔
563

564
    @staticmethod
6✔
565
    def work_dir(
6✔
566
        name: str, parent: Path, depth: int, be_file: bool = False
567
    ) -> Path | None:
568
        for _ in range(depth):
6✔
569
            if (f := parent.joinpath(name)).exists():
6✔
570
                if be_file:
6✔
571
                    return f
6✔
572
                return parent
6✔
573
            parent = parent.parent
6✔
574
        return None
6✔
575

576
    @classmethod
6✔
577
    def get_work_dir(
6✔
578
        cls: type[Self],
579
        name: str = TOML_FILE,
580
        cwd: Path | None = None,
581
        allow_cwd: bool = False,
582
        be_file: bool = False,
583
    ) -> Path:
584
        cwd = cwd or Path.cwd()
6✔
585
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
6✔
586
            return d
6✔
587
        if allow_cwd:
6✔
588
            return cls.get_root_dir(cwd)
6✔
589
        raise EnvError(f"{name} not found! Make sure this is a python project.")
6✔
590

591
    @classmethod
6✔
592
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
6✔
593
        toml_file = cls.get_work_dir(name, be_file=True)
6✔
594
        return toml_file.read_text("utf8")
6✔
595

596
    @classmethod
6✔
597
    def manage_by_poetry(cls: type[Self], cache: bool = False) -> bool:
6✔
598
        return cls.get_manage_tool(cache=cache) == "poetry"
6✔
599

600
    @classmethod
6✔
601
    def get_manage_tool(cls: type[Self], cache: bool = False) -> ToolName | None:
6✔
602
        if cache and cls._tool:
6✔
603
            return cls._tool
6✔
604
        try:
6✔
605
            text = cls.load_toml_text()
6✔
606
        except EnvError:
6✔
607
            return None
6✔
608
        with contextlib.suppress(KeyError, tomllib.TOMLDecodeError):
6✔
609
            doc = tomllib.loads(text)
6✔
610
            backend = doc["build-system"]["build-backend"]
6✔
611
            if "poetry" in backend:
6✔
612
                cls._tool = "poetry"
6✔
613
                return cls._tool
6✔
614
            work_dir = cls.get_work_dir(allow_cwd=True)
6✔
615
            uv_lock_exists = Path(work_dir, "uv.lock").exists()
6✔
616
            if "pdm" in backend:
6✔
617
                cls._tool = "pdm"
6✔
618
                if not Path(work_dir, "pdm.lock").exists() and (
6✔
619
                    uv_lock_exists or "[tool.uv]" in text
620
                ):
621
                    cls._tool = "uv"
×
622
                return cls._tool
6✔
623
            elif uv_lock_exists:
×
624
                cls._tool = "uv"
×
625
                return cls._tool
1✔
626
        for name in get_args(ToolName):
6✔
627
            if f"[tool.{name}]" in text:
6✔
628
                cls._tool = cast(ToolName, name)
6✔
629
                return cls._tool
6✔
630
        # Poetry 2.0 default to not include the '[tool.poetry]' section
631
        if cls.is_poetry_v2(text):
6✔
632
            cls._tool = "poetry"
×
633
            return cls._tool
×
634
        return None
6✔
635

636
    @staticmethod
6✔
637
    def python_exec_dir() -> Path:
6✔
638
        return Path(sys.executable).parent
6✔
639

640
    @classmethod
6✔
641
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
6✔
642
        root = cwd or Path.cwd()
6✔
643
        venv_parent = cls.python_exec_dir().parent.parent
6✔
644
        if root.is_relative_to(venv_parent):
6✔
645
            root = venv_parent
6✔
646
        return root
6✔
647

648
    @classmethod
6✔
649
    def is_pdm_project(cls, strict: bool = True, cache: bool = False) -> bool:
6✔
650
        if cls.get_manage_tool(cache=cache) != "pdm":
6✔
651
            return False
6✔
652
        if strict:
×
653
            lock_file = cls.get_work_dir() / "pdm.lock"
×
654
            return lock_file.exists()
×
655
        return True
×
656

657
    @classmethod
6✔
658
    def get_sync_command(cls, prod: bool = True, doc: dict | None = None) -> str:
6✔
659
        if cls.is_pdm_project():
6✔
660
            return "pdm sync" + " --prod" * prod
×
661
        elif cls.manage_by_poetry(cache=True):
6✔
662
            cmd = "poetry install"
6✔
663
            if prod:
6✔
664
                if doc is None:
6✔
665
                    doc = tomllib.loads(cls.load_toml_text())
6✔
666
                if doc.get("project", {}).get("dependencies") or any(
6✔
667
                    i != "python"
668
                    for i in doc.get("tool", {})
669
                    .get("poetry", {})
670
                    .get("dependencies", [])
671
                ):
672
                    cmd += " --only=main"
×
673
            return cmd
6✔
674
        elif cls.get_manage_tool(cache=True) == "uv":
×
675
            return "uv sync --inexact" + " --no-dev" * prod
×
676
        return ""
×
677

678
    @classmethod
6✔
679
    def sync_dependencies(cls, prod: bool = True) -> None:
6✔
680
        if cmd := cls.get_sync_command():
×
681
            run_and_echo(cmd)
×
682

683

684
class UpgradeDependencies(Project, DryRun):
6✔
685
    def __init__(
6✔
686
        self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
687
    ) -> None:
688
        super().__init__(_exit, dry)
6✔
689
        self._tool = tool
6✔
690

691
    class DevFlag(StrEnum):
6✔
692
        new = "[tool.poetry.group.dev.dependencies]"
6✔
693
        old = "[tool.poetry.dev-dependencies]"
6✔
694

695
    @staticmethod
6✔
696
    def parse_value(version_info: str, key: str) -> str:
6✔
697
        """Pick out the value for key in version info.
698

699
        Example::
700
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
701
            >>> UpgradeDependencies.parse_value(s, 'extras')
702
            'all'
703
            >>> UpgradeDependencies.parse_value(s, 'optional')
704
            'true'
705
            >>> UpgradeDependencies.parse_value(s, 'version')
706
            '^0.9.0'
707
        """
708
        sep = key + " = "
6✔
709
        rest = version_info.split(sep, 1)[-1].strip(" =")
6✔
710
        if rest.startswith("["):
6✔
711
            rest = rest[1:].split("]")[0]
6✔
712
        elif rest.startswith('"'):
6✔
713
            rest = rest[1:].split('"')[0]
6✔
714
        else:
715
            rest = rest.split(",")[0].split("}")[0]
6✔
716
        return rest.strip().replace('"', "")
6✔
717

718
    @staticmethod
6✔
719
    def no_need_upgrade(version_info: str, line: str) -> bool:
6✔
720
        if (v := version_info.replace(" ", "")).startswith("{url="):
6✔
721
            echo(f"No need to upgrade for: {line}")
6✔
722
            return True
6✔
723
        if (f := "version=") in v:
6✔
724
            v = v.split(f)[1].strip('"').split('"')[0]
6✔
725
        if v == "*":
6✔
726
            echo(f"Skip wildcard line: {line}")
6✔
727
            return True
6✔
728
        elif v == "[":
6✔
729
            echo(f"Skip complex dependence: {line}")
6✔
730
            return True
6✔
731
        elif v.startswith(">") or v.startswith("<") or v[0].isdigit():
6✔
732
            echo(f"Ignore bigger/smaller/equal: {line}")
6✔
733
            return True
6✔
734
        return False
6✔
735

736
    @classmethod
6✔
737
    def build_args(
6✔
738
        cls: type[Self], package_lines: list[str]
739
    ) -> tuple[list[str], dict[str, list[str]]]:
740
        args: list[str] = []  # ['typer[all]', 'fastapi']
6✔
741
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
6✔
742
        for no, line in enumerate(package_lines, 1):
6✔
743
            if (
6✔
744
                not (m := line.strip())
745
                or m.startswith("#")
746
                or m == "]"
747
                or (m.startswith("{") and m.strip(",").endswith("}"))
748
            ):
749
                continue
6✔
750
            try:
6✔
751
                package, version_info = m.split("=", 1)
6✔
752
            except ValueError as e:
6✔
753
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
6✔
754
            if (package := package.strip()).lower() == "python":
6✔
755
                continue
6✔
756
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
6✔
757
                continue
6✔
758
            if (extras_tip := "extras") in version_info:
6✔
759
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
6✔
760
            item = f'"{package}@latest"'
6✔
761
            key = None
6✔
762
            if (pf := "platform") in version_info:
6✔
763
                platform = cls.parse_value(version_info, pf)
6✔
764
                key = f"--{pf}={platform}"
6✔
765
            if (sc := "source") in version_info:
6✔
766
                source = cls.parse_value(version_info, sc)
6✔
767
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
6✔
768
            if "optional = true" in version_info:
6✔
769
                key = ("" if key is None else (key + " ")) + "--optional"
6✔
770
            if key is not None:
6✔
771
                specials[key] = specials.get(key, []) + [item]
6✔
772
            else:
773
                args.append(item)
6✔
774
        return args, specials
6✔
775

776
    @classmethod
6✔
777
    def should_with_dev(cls: type[Self]) -> bool:
6✔
778
        text = cls.load_toml_text()
6✔
779
        return cls.DevFlag.new in text or cls.DevFlag.old in text
6✔
780

781
    @staticmethod
6✔
782
    def parse_item(toml_str: str) -> list[str]:
6✔
783
        lines: list[str] = []
6✔
784
        for line in toml_str.splitlines():
6✔
785
            if (line := line.strip()).startswith("["):
6✔
786
                if lines:
6✔
787
                    break
6✔
788
            elif line:
6✔
789
                lines.append(line)
6✔
790
        return lines
6✔
791

792
    @classmethod
6✔
793
    def get_args(
6✔
794
        cls: type[Self], toml_text: str | None = None
795
    ) -> tuple[list[str], list[str], list[list[str]], str]:
796
        if toml_text is None:
6✔
797
            toml_text = cls.load_toml_text()
6✔
798
        main_title = "[tool.poetry.dependencies]"
6✔
799
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
6✔
800
            toml_text
801
        ):
802
            raise EnvError(
6✔
803
                f"{main_title} not found! Make sure this is a poetry project."
804
            )
805
        text = toml_text.split(main_title)[-1]
6✔
806
        dev_flag = "--group dev"
6✔
807
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
6✔
808
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
6✔
809
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
6✔
810
            dev_flag = "--dev"
6✔
811
        others: list[list[str]] = []
6✔
812
        try:
6✔
813
            main_toml, dev_toml = text.split(dev_title)
6✔
814
        except ValueError:
6✔
815
            dev_toml = ""
6✔
816
            main_toml = text
6✔
817
        mains = [] if no_main_deps else cls.parse_item(main_toml)
6✔
818
        devs = cls.parse_item(dev_toml)
6✔
819
        prod_packs, specials = cls.build_args(mains)
6✔
820
        if specials:
6✔
821
            others.extend([[k] + v for k, v in specials.items()])
6✔
822
        dev_packs, specials = cls.build_args(devs)
6✔
823
        if specials:
6✔
824
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
6✔
825
        return prod_packs, dev_packs, others, dev_flag
6✔
826

827
    @classmethod
6✔
828
    def gen_cmd(cls: type[Self]) -> str:
6✔
829
        main_args, dev_args, others, dev_flags = cls.get_args()
6✔
830
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
6✔
831

832
    @staticmethod
6✔
833
    def to_cmd(
6✔
834
        main_args: list[str],
835
        dev_args: list[str],
836
        others: list[list[str]],
837
        dev_flags: str,
838
    ) -> str:
839
        command = "poetry add "
6✔
840
        _upgrade = ""
6✔
841
        if main_args:
6✔
842
            _upgrade = command + " ".join(main_args)
6✔
843
        if dev_args:
6✔
844
            if _upgrade:
6✔
845
                _upgrade += " && "
6✔
846
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
6✔
847
        for single in others:
6✔
848
            _upgrade += f" && poetry add {' '.join(single)}"
6✔
849
        return _upgrade
6✔
850

851
    def gen(self) -> str:
6✔
852
        if self._tool == "uv":
6✔
853
            up = "uv lock --upgrade --verbose"
6✔
854
            deps = "uv sync --inexact --frozen --all-groups --all-extras"
6✔
855
            return f"{up} && {deps}"
6✔
856
        elif self._tool == "pdm":
6✔
857
            return "pdm update --verbose && pdm sync -G :all --frozen"
6✔
858
        return self.gen_cmd() + " && poetry lock && poetry update"
6✔
859

860

861
@cli.command()
6✔
862
def upgrade(
6✔
863
    tool: str = ToolOption,
864
    dry: bool = DryOption,
865
) -> None:
866
    """Upgrade dependencies in pyproject.toml to latest versions"""
867
    if not (tool := _ensure_str(tool)) or tool == ToolOption.default:
6✔
868
        tool = Project.get_manage_tool() or "uv"
6✔
869
    if tool in get_args(ToolName):
6✔
870
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
6✔
871
    else:
872
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
6✔
873
        raise typer.Exit(1)
6✔
874

875

876
class GitTag(DryRun):
6✔
877
    def __init__(self, message: str, dry: bool, no_sync: bool = False) -> None:
6✔
878
        self.message = message
6✔
879
        self._no_sync = no_sync
6✔
880
        super().__init__(dry=dry)
6✔
881

882
    @staticmethod
6✔
883
    def has_v_prefix() -> bool:
6✔
884
        return "v" in capture_cmd_output("git tag")
6✔
885

886
    def should_push(self) -> bool:
6✔
887
        return "git push" in self.git_status
6✔
888

889
    def gen(self) -> str:
6✔
890
        should_sync, _version = get_current_version(verbose=False, check_version=True)
6✔
891
        if self.has_v_prefix():
6✔
892
            # Add `v` at prefix to compare with bumpversion tool
893
            _version = "v" + _version
6✔
894
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
6✔
895
        if self.should_push():
6✔
896
            cmd += " && git push"
6✔
897
        if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
6✔
898
            cmd = f"{sync} && " + cmd
×
899
        return cmd
6✔
900

901
    @cached_property
6✔
902
    def git_status(self) -> str:
6✔
903
        return capture_cmd_output("git status")
6✔
904

905
    def mark_tag(self) -> bool:
6✔
906
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
6✔
907
            "无文件要提交,干净的工作区" not in self.git_status
908
        ):
909
            run_and_echo("git status")
6✔
910
            echo("ERROR: Please run git commit to make sure working tree is clean!")
6✔
911
            return False
6✔
912
        return bool(super().run())
6✔
913

914
    def run(self) -> None:
6✔
915
        if self.mark_tag() and not self.dry:
6✔
916
            echo("You may want to publish package:\n poetry publish --build")
6✔
917

918

919
@cli.command()
6✔
920
def tag(
6✔
921
    message: str = Option("", "-m", "--message"),
922
    no_sync: bool = Option(
923
        False, "--no-sync", help="Do not run sync command to update version"
924
    ),
925
    dry: bool = DryOption,
926
) -> None:
927
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
928
    GitTag(message, dry=dry, no_sync=_ensure_bool(no_sync)).run()
6✔
929

930

931
class LintCode(DryRun):
6✔
932
    def __init__(
6✔
933
        self,
934
        args: list[str] | str | None,
935
        check_only: bool = False,
936
        _exit: bool = False,
937
        dry: bool = False,
938
        bandit: bool = False,
939
        skip_mypy: bool = False,
940
        dmypy: bool = False,
941
        tool: str = ToolOption.default,
942
        prefix: bool = False,
943
    ) -> None:
944
        self.args = args
6✔
945
        self.check_only = check_only
6✔
946
        self._bandit = bandit
6✔
947
        self._skip_mypy = skip_mypy
6✔
948
        self._use_dmypy = dmypy
6✔
949
        self._tool = tool
6✔
950
        self._prefix = prefix
6✔
951
        super().__init__(_exit, dry)
6✔
952

953
    @staticmethod
6✔
954
    def check_lint_tool_installed() -> bool:
6✔
955
        return check_call("ruff --version")
6✔
956

957
    @staticmethod
6✔
958
    def missing_mypy_exec() -> bool:
6✔
959
        return shutil.which("mypy") is None
6✔
960

961
    @staticmethod
6✔
962
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
6✔
963
        return (
6✔
964
            paths == "."
965
            and any(t.startswith("mypy") for t in tools)
966
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
967
        )
968

969
    @staticmethod
6✔
970
    def get_package_name() -> str:
6✔
971
        root = Project.get_work_dir(allow_cwd=True)
6✔
972
        module_name = root.name.replace("-", "_").replace(" ", "_")
6✔
973
        package_maybe = (module_name, "src")
6✔
974
        for name in package_maybe:
6✔
975
            if root.joinpath(name).is_dir():
6✔
976
                return name
6✔
977
        return "."
6✔
978

979
    @classmethod
6✔
980
    def to_cmd(
6✔
981
        cls: type[Self],
982
        paths: str = ".",
983
        check_only: bool = False,
984
        bandit: bool = False,
985
        skip_mypy: bool = False,
986
        use_dmypy: bool = False,
987
        tool: str = ToolOption.default,
988
        with_prefix: bool = False,
989
    ) -> str:
990
        if paths != "." and all(i.endswith(".html") for i in paths.split()):
6✔
991
            return f"prettier -w {paths}"
6✔
992
        cmd = ""
6✔
993
        tools = ["ruff format", "ruff check --extend-select=I,B,SIM --fix", "mypy"]
6✔
994
        if check_only:
6✔
995
            tools[0] += " --check"
6✔
996
        if check_only or load_bool("NO_FIX"):
6✔
997
            tools[1] = tools[1].replace(" --fix", "")
6✔
998
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
6✔
999
            # Sometimes mypy is too slow
1000
            tools = tools[:-1]
6✔
1001
        elif load_bool("IGNORE_MISSING_IMPORTS"):
6✔
1002
            tools[-1] += " --ignore-missing-imports"
6✔
1003
        lint_them = " && ".join(
6✔
1004
            "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
1005
        )
1006
        if ruff_exists := cls.check_lint_tool_installed():
6✔
1007
            # `ruff <command>` get the same result with `pdm run ruff <command>`
1008
            # While `mypy .`(installed global and env not activated),
1009
            #   does not the same as `pdm run mypy .`
1010
            lint_them = " && ".join(
6✔
1011
                ("" if tool.startswith("ruff") else "{0}")
1012
                + (
1013
                    "{%d} {1}" % i  # noqa: UP031
1014
                )
1015
                for i, tool in enumerate(tools, 2)
1016
            )
1017
        prefix = ""
6✔
1018
        should_run_by_tool = with_prefix
6✔
1019
        if not should_run_by_tool:
6✔
1020
            if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(
6✔
1021
                ".local/bin"
1022
            ):
1023
                if not ruff_exists:
6✔
1024
                    should_run_by_tool = True
6✔
1025
                    command = "pipx install ruff"
6✔
1026
                    if shutil.which("pipx") is None:
6✔
1027
                        ensure_pipx = "pip install --user pipx\n  pipx ensurepath\n  "
×
1028
                        command = ensure_pipx + command
×
1029
                    yellow_warn(
6✔
1030
                        "You may need to run the following command"
1031
                        f" to install ruff:\n\n  {command}\n"
1032
                    )
1033
                elif "mypy" in str(tools) and cls.missing_mypy_exec():
6✔
1034
                    should_run_by_tool = True
6✔
1035
                    if check_call('python -c "import fast_dev_cli"'):
6✔
1036
                        command = 'python -m pip install -U "fast-dev-cli"'
6✔
1037
                        yellow_warn(
6✔
1038
                            "You may need to run the following command"
1039
                            f" to install lint tools:\n\n  {command}\n"
1040
                        )
1041
            else:
1042
                should_run_by_tool = True
6✔
1043
        if should_run_by_tool and tool:
6✔
1044
            if tool == ToolOption.default:
6✔
1045
                tool = Project.get_manage_tool() or ""
6✔
1046
            if tool:
6✔
1047
                prefix = (
6✔
1048
                    bin_dir
1049
                    if tool == "uv" and Path(bin_dir := ".venv/bin/").exists()
1050
                    else (tool + " run ")
1051
                )
1052
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
6✔
1053
            tools[-1] = "dmypy run"
6✔
1054
        cmd += lint_them.format(prefix, paths, *tools)
6✔
1055
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
6✔
1056
            command = prefix + "bandit"
6✔
1057
            if Path("pyproject.toml").exists():
6✔
1058
                toml_text = Project.load_toml_text()
6✔
1059
                if "[tool.bandit" in toml_text:
6✔
1060
                    command += " -c pyproject.toml"
6✔
1061
            if paths == "." and " -c " not in command:
6✔
1062
                paths = cls.get_package_name()
6✔
1063
            command += f" -r {paths}"
6✔
1064
            cmd += " && " + command
6✔
1065
        return cmd
6✔
1066

1067
    def gen(self) -> str:
6✔
1068
        paths = "."
6✔
1069
        if args := self.args:
6✔
1070
            ps = args.split() if isinstance(args, str) else [str(i) for i in args]
6✔
1071
            if len(ps) == 1:
6✔
1072
                paths = ps[0]
6✔
1073
                if paths != "." and paths.endswith(".") and not Path(paths).exists():
6✔
1074
                    for ft in ("py", "html"):
×
1075
                        name = paths + ft
×
1076
                        if Path(name).exists():
×
1077
                            paths = name
×
1078
                            break
×
1079
            else:
1080
                paths = " ".join(ps)
×
1081
        return self.to_cmd(
6✔
1082
            paths,
1083
            self.check_only,
1084
            self._bandit,
1085
            self._skip_mypy,
1086
            self._use_dmypy,
1087
            tool=self._tool,
1088
            with_prefix=self._prefix,
1089
        )
1090

1091

1092
def parse_files(args: list[str] | tuple[str, ...]) -> list[str]:
6✔
1093
    return [i for i in args if not i.startswith("-")]
6✔
1094

1095

1096
def lint(
6✔
1097
    files: list[str] | str | None = None,
1098
    dry: bool = False,
1099
    bandit: bool = False,
1100
    skip_mypy: bool = False,
1101
    dmypy: bool = False,
1102
    tool: str = ToolOption.default,
1103
    prefix: bool = False,
1104
) -> None:
1105
    if files is None:
6✔
1106
        files = parse_files(sys.argv[1:])
6✔
1107
    if files and files[0] == "lint":
6✔
1108
        files = files[1:]
6✔
1109
    LintCode(
6✔
1110
        files,
1111
        dry=dry,
1112
        skip_mypy=skip_mypy,
1113
        bandit=bandit,
1114
        dmypy=dmypy,
1115
        tool=tool,
1116
        prefix=prefix,
1117
    ).run()
1118

1119

1120
def check(
6✔
1121
    files: list[str] | str | None = None,
1122
    dry: bool = False,
1123
    bandit: bool = False,
1124
    skip_mypy: bool = False,
1125
    dmypy: bool = False,
1126
    tool: str = ToolOption.default,
1127
) -> None:
1128
    LintCode(
6✔
1129
        files,
1130
        check_only=True,
1131
        _exit=True,
1132
        dry=dry,
1133
        bandit=bandit,
1134
        skip_mypy=skip_mypy,
1135
        dmypy=dmypy,
1136
        tool=tool,
1137
    ).run()
1138

1139

1140
@cli.command(name="lint")
6✔
1141
def make_style(
6✔
1142
    files: Optional[list[str]] = typer.Argument(default=None),  # noqa:B008
1143
    check_only: bool = Option(False, "--check-only", "-c"),
1144
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1145
    prefix: bool = Option(
1146
        False,
1147
        "--prefix",
1148
        help="Run lint command with tool prefix, e.g.: pdm run ruff ...",
1149
    ),
1150
    skip_mypy: bool = Option(False, "--skip-mypy"),
1151
    use_dmypy: bool = Option(
1152
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
1153
    ),
1154
    tool: str = ToolOption,
1155
    dry: bool = DryOption,
1156
) -> None:
1157
    """Run: ruff check/format to reformat code and then mypy to check"""
1158
    if getattr(files, "default", files) is None:
6✔
1159
        files = ["."]
6✔
1160
    elif isinstance(files, str):
6✔
1161
        files = [files]
6✔
1162
    skip = _ensure_bool(skip_mypy)
6✔
1163
    dmypy = _ensure_bool(use_dmypy)
6✔
1164
    bandit = _ensure_bool(bandit)
6✔
1165
    prefix = _ensure_bool(prefix)
6✔
1166
    tool = _ensure_str(tool)
6✔
1167
    kwargs = {"dry": dry, "skip_mypy": skip, "dmypy": dmypy, "bandit": bandit}
6✔
1168
    if _ensure_bool(check_only):
6✔
1169
        check(files, tool=tool, **kwargs)
6✔
1170
    else:
1171
        lint(files, prefix=prefix, tool=tool, **kwargs)
6✔
1172

1173

1174
@cli.command(name="check")
6✔
1175
def only_check(
6✔
1176
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
1177
    skip_mypy: bool = Option(False, "--skip-mypy"),
1178
    dry: bool = DryOption,
1179
) -> None:
1180
    """Check code style without reformat"""
1181
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
6✔
1182

1183

1184
class Sync(DryRun):
6✔
1185
    def __init__(
6✔
1186
        self, filename: str, extras: str, save: bool, dry: bool = False
1187
    ) -> None:
1188
        self.filename = filename
6✔
1189
        self.extras = extras
6✔
1190
        self._save = save
6✔
1191
        super().__init__(dry=dry)
6✔
1192

1193
    def gen(self) -> str:
6✔
1194
        extras, save = self.extras, self._save
6✔
1195
        should_remove = not Path.cwd().joinpath(self.filename).exists()
6✔
1196
        if not (tool := Project.get_manage_tool()):
6✔
1197
            if should_remove or not is_venv():
6✔
1198
                raise EnvError("There project is not managed by uv/pdm/poetry!")
6✔
1199
            return f"python -m pip install -r {self.filename}"
6✔
1200
        prefix = "" if is_venv() else f"{tool} run "
6✔
1201
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
6✔
1202
        export_cmd = "uv export --no-hashes --all-extras --frozen"
6✔
1203
        if tool in ("poetry", "pdm"):
6✔
1204
            export_cmd = f"{tool} export --without-hashes --with=dev"
6✔
1205
            if tool == "poetry":
6✔
1206
                ensure_pip = ""
6✔
1207
                if not UpgradeDependencies.should_with_dev():
6✔
1208
                    export_cmd = export_cmd.replace(" --with=dev", "")
6✔
1209
                if extras and isinstance(extras, (str, list)):
6✔
1210
                    export_cmd += f" --{extras=}".replace("'", '"')
6✔
1211
            elif check_call(prefix + "python -m pip --version"):
6✔
1212
                ensure_pip = ""
6✔
1213
        elif check_call(prefix + "python -m pip --version"):
6✔
1214
            ensure_pip = ""
6✔
1215
        install_cmd = (
6✔
1216
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
1217
        )
1218
        if should_remove and not save:
6✔
1219
            install_cmd += " && rm -f {0}"
6✔
1220
        return install_cmd.format(self.filename, prefix, export_cmd)
6✔
1221

1222

1223
@cli.command()
6✔
1224
def sync(
6✔
1225
    filename: str = "dev_requirements.txt",
1226
    extras: str = Option("", "--extras", "-E"),
1227
    save: bool = Option(
1228
        False, "--save", "-s", help="Whether save the requirement file"
1229
    ),
1230
    dry: bool = DryOption,
1231
) -> None:
1232
    """Export dependencies by poetry to a txt file then install by pip."""
1233
    Sync(filename, extras, save, dry=dry).run()
6✔
1234

1235

1236
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
6✔
1237
    for name in ("test.sh", "test.py"):
6✔
1238
        if (file := path / name).exists():
6✔
1239
            return file
6✔
1240
    return None
6✔
1241

1242

1243
def test(dry: bool, ignore_script: bool = False) -> None:
6✔
1244
    cwd = Path.cwd()
6✔
1245
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
6✔
1246
    script_dir = root / "scripts"
6✔
1247
    if not _ensure_bool(ignore_script) and (
6✔
1248
        test_script := _should_run_test_script(script_dir)
1249
    ):
1250
        cmd = f"{os.path.relpath(test_script, root)}"
6✔
1251
        if cwd != root:
6✔
1252
            cmd = f"cd {root} && " + cmd
6✔
1253
    else:
1254
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
6✔
1255
        if not is_venv() or not check_call("coverage --version"):
6✔
1256
            sep = " && "
6✔
1257
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
6✔
1258
            cmd = sep.join(prefix + i for i in cmd.split(sep))
6✔
1259
    exit_if_run_failed(cmd, dry=dry)
6✔
1260

1261

1262
@cli.command(name="test")
6✔
1263
def coverage_test(
6✔
1264
    dry: bool = DryOption,
1265
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1266
) -> None:
1267
    """Run unittest by pytest and report coverage"""
1268
    return test(dry, ignore_script)
6✔
1269

1270

1271
class Publish:
6✔
1272
    class CommandEnum(StrEnum):
6✔
1273
        poetry = "poetry publish --build"
6✔
1274
        pdm = "pdm publish"
6✔
1275
        uv = "uv build && uv publish"
6✔
1276
        twine = "python -m build && twine upload"
6✔
1277

1278
    @classmethod
6✔
1279
    def gen(cls) -> str:
6✔
1280
        if tool := Project.get_manage_tool():
6✔
1281
            return cls.CommandEnum[tool]
6✔
1282
        return cls.CommandEnum.twine
6✔
1283

1284

1285
@cli.command()
6✔
1286
def upload(
6✔
1287
    dry: bool = DryOption,
1288
) -> None:
1289
    """Shortcut for package publish"""
1290
    cmd = Publish.gen()
6✔
1291
    exit_if_run_failed(cmd, dry=dry)
6✔
1292

1293

1294
def dev(
6✔
1295
    port: int | None | OptionInfo,
1296
    host: str | None | OptionInfo,
1297
    file: str | None | ArgumentInfo = None,
1298
    dry: bool = False,
1299
) -> None:
1300
    cmd = "fastapi dev"
6✔
1301
    no_port_yet = True
6✔
1302
    if file is not None:
6✔
1303
        try:
6✔
1304
            port = int(str(file))
6✔
1305
        except ValueError:
6✔
1306
            cmd += f" {file}"
6✔
1307
        else:
1308
            if port != 8000:
6✔
1309
                cmd += f" --port={port}"
6✔
1310
                no_port_yet = False
6✔
1311
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
6✔
1312
        cmd += f" --port={port}"
6✔
1313
    if (host := getattr(host, "default", host)) and host not in (
6✔
1314
        "localhost",
1315
        "127.0.0.1",
1316
    ):
1317
        cmd += f" --host={host}"
6✔
1318
    exit_if_run_failed(cmd, dry=dry)
6✔
1319

1320

1321
@cli.command(name="dev")
6✔
1322
def runserver(
6✔
1323
    file_or_port: Optional[str] = typer.Argument(default=None),
1324
    port: Optional[int] = Option(None, "-p", "--port"),
1325
    host: Optional[str] = Option(None, "-h", "--host"),
1326
    dry: bool = DryOption,
1327
) -> None:
1328
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1329
    if getattr(file_or_port, "default", file_or_port):
6✔
1330
        dev(port, host, file=file_or_port, dry=dry)
6✔
1331
    else:
1332
        dev(port, host, dry=dry)
6✔
1333

1334

1335
@cli.command(name="exec")
6✔
1336
def run_by_subprocess(cmd: str, dry: bool = DryOption) -> None:
6✔
1337
    """Run cmd by subprocess, auto set shell=True when cmd contains '|>'"""
1338
    try:
6✔
1339
        rc = run_and_echo(cmd, verbose=True, dry=_ensure_bool(dry))
6✔
1340
    except FileNotFoundError as e:
6✔
1341
        if e.filename == cmd.split()[0]:
6✔
1342
            echo(f"Command not found: {e.filename}")
6✔
1343
            raise Exit(1) from None
6✔
1344
        raise e
1✔
1345
    else:
1346
        if rc:
6✔
1347
            raise Exit(rc)
6✔
1348

1349

1350
class MakeDeps(DryRun):
6✔
1351
    def __init__(
6✔
1352
        self,
1353
        tool: str,
1354
        prod: bool = False,
1355
        dry: bool = False,
1356
        active: bool = True,
1357
        inexact: bool = True,
1358
    ) -> None:
1359
        self._tool = tool
6✔
1360
        self._prod = prod
6✔
1361
        self._active = active
6✔
1362
        self._inexact = inexact
6✔
1363
        super().__init__(dry=dry)
6✔
1364

1365
    def should_ensure_pip(self) -> bool:
6✔
1366
        return True
6✔
1367

1368
    def should_upgrade_pip(self) -> bool:
6✔
1369
        return True
×
1370

1371
    def get_groups(self) -> list[str]:
6✔
1372
        if self._prod:
6✔
1373
            return []
6✔
1374
        return ["dev"]
6✔
1375

1376
    def gen(self) -> str:
6✔
1377
        if self._tool == "pdm":
6✔
1378
            return "pdm sync " + ("--prod" if self._prod else "-G :all")
6✔
1379
        elif self._tool == "uv":
6✔
1380
            uv_sync = "uv sync" + " --inexact" * self._inexact
6✔
1381
            if self._active:
6✔
1382
                uv_sync += " --active"
6✔
1383
            return uv_sync + ("" if self._prod else " --all-extras --all-groups")
6✔
1384
        elif self._tool == "poetry":
6✔
1385
            return "poetry install " + (
6✔
1386
                "--only=main" if self._prod else "--all-extras --all-groups"
1387
            )
1388
        else:
1389
            cmd = "python -m pip install -e ."
6✔
1390
            if gs := self.get_groups():
6✔
1391
                cmd += " " + " ".join(f"--group {g}" for g in gs)
6✔
1392
            upgrade = "python -m pip install --upgrade pip"
6✔
1393
            if self.should_ensure_pip():
6✔
1394
                cmd = f"python -m ensurepip && {upgrade} && {cmd}"
6✔
1395
            elif self.should_upgrade_pip():
×
1396
                cmd = "{upgrade} && {cmd}"
×
1397
            return cmd
6✔
1398

1399

1400
@cli.command(name="deps")
6✔
1401
def make_deps(
6✔
1402
    prod: bool = Option(
1403
        False,
1404
        "--prod",
1405
        help="Only instead production dependencies.",
1406
    ),
1407
    tool: str = ToolOption,
1408
    use_uv: bool = Option(False, "--uv", help="Use `uv` to install deps"),
1409
    use_pdm: bool = Option(False, "--pdm", help="Use `pdm` to install deps"),
1410
    use_pip: bool = Option(False, "--pip", help="Use `pip` to install deps"),
1411
    use_poetry: bool = Option(False, "--poetry", help="Use `poetry` to install deps"),
1412
    active: bool = Option(
1413
        True, help="Add `--active` to uv sync command(Only work for uv project)"
1414
    ),
1415
    inexact: bool = Option(
1416
        True, help="Add `--inexact` to uv sync command(Only work for uv project)"
1417
    ),
1418
    dry: bool = DryOption,
1419
) -> None:
1420
    """Run: ruff check/format to reformat code and then mypy to check"""
1421
    if use_uv + use_pdm + use_pip + use_poetry > 1:
×
1422
        raise UsageError("`--uv/--pdm/--pip/--poetry` can only choose one!")
×
1423
    if use_uv:
×
1424
        tool = "uv"
×
1425
    elif use_pdm:
×
1426
        tool = "pdm"
×
1427
    elif use_pip:
×
1428
        tool = "pip"
×
1429
    elif use_poetry:
×
1430
        tool = "poetry"
×
1431
    elif tool == ToolOption.default:
×
1432
        tool = Project.get_manage_tool(cache=True) or "pip"
×
1433
    MakeDeps(tool, prod, active=active, inexact=inexact, dry=dry).run()
×
1434

1435

1436
class UvPypi(DryRun):
6✔
1437
    PYPI = "https://pypi.org/simple"
6✔
1438
    HOST = "https://files.pythonhosted.org"
6✔
1439

1440
    def __init__(self, lock_file: Path, dry: bool, verbose: bool, quiet: bool) -> None:
6✔
1441
        super().__init__(dry=dry)
6✔
1442
        self.lock_file = lock_file
6✔
1443
        self._verbose = _ensure_bool(verbose)
6✔
1444
        self._quiet = _ensure_bool(quiet)
6✔
1445

1446
    def run(self) -> None:
6✔
1447
        try:
6✔
1448
            rc = self.update_lock(self.lock_file, self._verbose, self._quiet)
6✔
1449
        except ValueError as e:
×
1450
            secho(str(e), fg=typer.colors.RED)
×
1451
            raise Exit(1) from e
×
1452
        else:
1453
            if rc != 0:
6✔
1454
                raise Exit(rc)
6✔
1455

1456
    @classmethod
6✔
1457
    def update_lock(cls, p: Path, verbose: bool, quiet: bool) -> int:
6✔
1458
        text = p.read_text("utf-8")
6✔
1459
        registry_pattern = r'(registry = ")(.*?)"'
6✔
1460
        replace_registry = functools.partial(
6✔
1461
            re.sub, registry_pattern, rf'\1{cls.PYPI}"'
1462
        )
1463
        registry_urls = {i[1] for i in re.findall(registry_pattern, text)}
6✔
1464
        download_pattern = r'(url = ")(https?://.*?)(/packages/.*?\.)(gz|whl)"'
6✔
1465
        replace_host = functools.partial(
6✔
1466
            re.sub, download_pattern, rf'\1{cls.HOST}\3\4"'
1467
        )
1468
        download_hosts = {i[1] for i in re.findall(download_pattern, text)}
6✔
1469
        if not registry_urls:
6✔
1470
            raise ValueError(f"Failed to find pattern {registry_pattern!r} in {p}")
×
1471
        if len(registry_urls) == 1:
6✔
1472
            current_registry = registry_urls.pop()
6✔
1473
            if current_registry == cls.PYPI:
6✔
1474
                if download_hosts == {cls.HOST}:
6✔
1475
                    if verbose:
6✔
1476
                        echo(f"Registry of {p} is {cls.PYPI}, no need to change.")
×
1477
                    return 0
6✔
1478
            else:
1479
                text = replace_registry(text)
6✔
1480
                if verbose:
6✔
1481
                    echo(f"{current_registry} --> {cls.PYPI}")
×
1482
        else:
1483
            # TODO: ask each one to confirm replace
1484
            text = replace_registry(text)
×
1485
            if verbose:
×
1486
                for current_registry in sorted(registry_urls):
×
1487
                    echo(f"{current_registry} --> {cls.PYPI}")
×
1488
        if len(download_hosts) == 1:
6✔
1489
            current_host = download_hosts.pop()
6✔
1490
            if current_host != cls.HOST:
6✔
1491
                text = replace_host(text)
6✔
1492
                if verbose:
6✔
1493
                    print(current_host, "-->", cls.HOST)
×
1494
        elif download_hosts:
×
1495
            # TODO: ask each one to confirm replace
1496
            text = replace_host(text)
×
1497
            if verbose:
×
1498
                for current_host in sorted(download_hosts):
×
1499
                    echo(f"{current_host} --> {cls.HOST}")
×
1500
        size = p.write_text(text, encoding="utf-8")
6✔
1501
        if verbose:
6✔
1502
            echo(f"Updated {p} with {size} bytes.")
×
1503
        if quiet:
6✔
1504
            return 0
6✔
1505
        return 1
6✔
1506

1507

1508
@cli.command()
6✔
1509
def pypi(
6✔
1510
    file: Optional[str] = typer.Argument(default=None),
1511
    dry: bool = DryOption,
1512
    verbose: bool = False,
1513
    quiet: bool = False,
1514
) -> None:
1515
    """Change registry of uv.lock to be pypi.org"""
1516
    if not (p := Path(_ensure_str(file) or "uv.lock")).exists() and not (
6✔
1517
        (p := Project.get_work_dir() / p.name).exists()
1518
    ):
1519
        yellow_warn(f"{p.name!r} not found!")
6✔
1520
        return
6✔
1521
    UvPypi(p, dry, verbose, quiet).run()
6✔
1522

1523

1524
def version_callback(value: bool) -> None:
6✔
1525
    if value:
6✔
1526
        echo("Fast Dev Cli Version: " + typer.style(__version__, bold=True))
6✔
1527
        raise Exit()
6✔
1528

1529

1530
@cli.callback()
1531
def common(
1532
    version: bool = Option(
1533
        None,
1534
        "--version",
1535
        "-V",
1536
        callback=version_callback,
1537
        is_eager=True,
1538
        help="Show the version of this tool",
1539
    ),
1540
) -> None: ...
1541

1542

1543
def main() -> None:
6✔
1544
    cli()
6✔
1545

1546

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

© 2025 Coveralls, Inc