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

waketzheng / fast-dev-cli / 14513207452

17 Apr 2025 10:05AM UTC coverage: 99.705% (+0.9%) from 98.815%
14513207452

push

github

waketzheng
tests: improve coverage

677 of 679 relevant lines covered (99.71%)

4.99 hits per line

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

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

3
import importlib.metadata as importlib_metadata
5✔
4
import os
5✔
5
import re
5✔
6
import shlex
5✔
7
import subprocess  # nosec:B404
5✔
8
import sys
5✔
9
from functools import cached_property
5✔
10
from pathlib import Path
5✔
11
from typing import Literal, Optional, cast, get_args  # Optional is required by typers
5✔
12

13
import emoji
5✔
14
import typer
5✔
15
from typer import Exit, Option, echo, secho
5✔
16
from typer.models import ArgumentInfo, OptionInfo
5✔
17

18
try:
5✔
19
    from . import __version__
5✔
20
except ImportError:  # pragma: no cover
21
    from importlib import import_module as _import  # For local unittest
22

23
    __version__ = _import(Path(__file__).parent.name).__version__
24

25
if sys.version_info >= (3, 11):  # pragma: no cover
26
    from enum import StrEnum
27
    from typing import Self
28

29
    import tomllib
30
else:  # pragma: no cover
31
    from enum import Enum
32

33
    import tomli as tomllib
34
    from typing_extensions import Self
35

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

39

40
cli = typer.Typer()
5✔
41
DryOption = Option(False, "--dry", help="Only print, not really run shell command")
5✔
42
TOML_FILE = "pyproject.toml"
5✔
43
ToolName = Literal["poetry", "pdm", "uv"]
5✔
44
ToolOption = Option(
5✔
45
    "auto", "--tool", help="Explicit declare manage tool (default to auto detect)"
46
)
47

48

49
class ShellCommandError(Exception): ...
5✔
50

51

52
def poetry_module_name(name: str) -> str:
5✔
53
    """Get module name that generated by `poetry new`"""
54
    from packaging.utils import canonicalize_name
5✔
55

56
    return canonicalize_name(name).replace("-", "_").replace(" ", "_")
5✔
57

58

59
def load_bool(name: str, default=False) -> bool:
5✔
60
    if not (v := os.getenv(name)):
5✔
61
        return default
5✔
62
    if (lower := v.lower()) in ("0", "false", "f", "off", "no", "n"):
5✔
63
        return False
5✔
64
    elif lower in ("1", "true", "t", "on", "yes", "y"):
5✔
65
        return True
5✔
66
    secho(f"WARNING: can not convert value({v!r}) of {name} to bool!")
5✔
67
    return default
5✔
68

69

70
def is_venv() -> bool:
5✔
71
    """Whether in a virtual environment(also work for poetry)"""
72
    return hasattr(sys, "real_prefix") or (
5✔
73
        hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
74
    )
75

76

77
def _run_shell(cmd: list[str] | str, **kw) -> subprocess.CompletedProcess:
5✔
78
    if isinstance(cmd, str):
5✔
79
        kw.setdefault("shell", True)
5✔
80
    return subprocess.run(cmd, **kw)  # nosec:B603
5✔
81

82

83
def run_and_echo(cmd: str, *, dry=False, verbose=True, **kw) -> int:
5✔
84
    """Run shell command with subprocess and print it"""
85
    if verbose:
5✔
86
        echo(f"--> {cmd}")
5✔
87
    if dry:
5✔
88
        return 0
5✔
89
    return _run_shell(cmd, **kw).returncode
5✔
90

91

92
def check_call(cmd: str) -> bool:
5✔
93
    r = _run_shell(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
5✔
94
    return r.returncode == 0
5✔
95

96

97
def capture_cmd_output(command: list[str] | str, *, raises=False, **kw) -> str:
5✔
98
    if isinstance(command, str) and not kw.get("shell"):
5✔
99
        command = shlex.split(command)
5✔
100
    r = _run_shell(command, capture_output=True, encoding="utf-8", **kw)
5✔
101
    if raises and r.returncode != 0:
5✔
102
        raise ShellCommandError(r.stderr)
5✔
103
    return r.stdout.strip() or r.stderr
5✔
104

105

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

109

110
def read_version_from_file(
5✔
111
    package_name: str, work_dir=None, toml_text: str | None = None
112
) -> str:
113
    if toml_text is None:
5✔
114
        toml_text = Project.load_toml_text()
5✔
115
    pattern = re.compile(r"version\s*=")
5✔
116
    invalid = ("0", "0.0.0")
5✔
117
    for line in toml_text.splitlines():
5✔
118
        if pattern.match(line):
5✔
119
            version = _parse_version(line, pattern)
5✔
120
            if version.startswith("{") or version in invalid:
5✔
121
                break
5✔
122
            return version
5✔
123
    if work_dir is None:
5✔
124
        work_dir = Project.get_work_dir()
5✔
125
    package_dir = work_dir / package_name
5✔
126
    if (
5✔
127
        not (init_file := package_dir / "__init__.py").exists()
128
        and not (init_file := work_dir / "src" / package_name / init_file.name).exists()
129
        and not (init_file := work_dir / "app" / init_file.name).exists()
130
    ):
131
        secho("WARNING: __init__.py file does not exist!")
5✔
132
        return "0.0.0"
5✔
133
    pattern = re.compile(r"__version__\s*=")
5✔
134
    for line in init_file.read_text("utf-8").splitlines():
5✔
135
        if pattern.match(line):
5✔
136
            return _parse_version(line, pattern)
5✔
137
    secho(f"WARNING: can not find '__version__' var in {init_file}!")
5✔
138
    return "0.0.0"
5✔
139

140

141
def get_current_version(
5✔
142
    verbose=False, is_poetry: bool | None = None, package_name: str | None = None
143
) -> str:
144
    if is_poetry is None:
5✔
145
        is_poetry = Project.manage_by_poetry()
5✔
146
    if not is_poetry:
5✔
147
        work_dir = None
5✔
148
        if package_name is None:
5✔
149
            work_dir = Project.get_work_dir()
5✔
150
            package_name = re.sub(r"[- ]", "_", work_dir.name)
5✔
151
        try:
5✔
152
            return importlib_metadata.version(package_name)
5✔
153
        except importlib_metadata.PackageNotFoundError:
5✔
154
            return read_version_from_file(package_name, work_dir)
5✔
155

156
    cmd = ["poetry", "version", "-s"]
5✔
157
    if verbose:
5✔
158
        echo(f"--> {' '.join(cmd)}")
5✔
159
    if out := capture_cmd_output(cmd, raises=True):
5✔
160
        out = out.splitlines()[-1].strip().split()[-1]
5✔
161
    return out
5✔
162

163

164
def _ensure_bool(value: bool | OptionInfo) -> bool:
5✔
165
    if not isinstance(value, bool):
5✔
166
        value = getattr(value, "default", False)
5✔
167
    return value
5✔
168

169

170
def _ensure_str(value: str | OptionInfo) -> str:
5✔
171
    if not isinstance(value, str):
5✔
172
        value = getattr(value, "default", "")
5✔
173
    return value
5✔
174

175

176
def exit_if_run_failed(
5✔
177
    cmd: str, env=None, _exit=False, dry=False, **kw
178
) -> subprocess.CompletedProcess:
179
    run_and_echo(cmd, dry=True)
5✔
180
    if _ensure_bool(dry):
5✔
181
        return subprocess.CompletedProcess("", 0)
5✔
182
    if env is not None:
5✔
183
        env = {**os.environ, **env}
5✔
184
    r = _run_shell(cmd, env=env, **kw)
5✔
185
    if rc := r.returncode:
5✔
186
        if _exit:
5✔
187
            sys.exit(rc)
5✔
188
        raise Exit(rc)
5✔
189
    return r
5✔
190

191

192
class DryRun:
5✔
193
    def __init__(self: Self, _exit=False, dry=False) -> None:
5✔
194
        self.dry = dry
5✔
195
        self._exit = _exit
5✔
196

197
    def gen(self: Self) -> str:
5✔
198
        raise NotImplementedError
5✔
199

200
    def run(self: Self) -> None:
5✔
201
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
5✔
202

203

204
class BumpUp(DryRun):
5✔
205
    class PartChoices(StrEnum):
5✔
206
        patch = "patch"
5✔
207
        minor = "minor"
5✔
208
        major = "major"
5✔
209

210
    def __init__(
5✔
211
        self: Self, commit: bool, part: str, filename: str | None = None, dry=False
212
    ) -> None:
213
        self.commit = commit
5✔
214
        self.part = part
5✔
215
        if filename is None:
5✔
216
            filename = self.parse_filename()
5✔
217
        self.filename = filename
5✔
218
        super().__init__(dry=dry)
5✔
219

220
    @staticmethod
5✔
221
    def get_last_commit_message(raises=False) -> str:
5✔
222
        cmd = 'git show --pretty=format:"%s" -s HEAD'
5✔
223
        return capture_cmd_output(cmd, raises=raises)
5✔
224

225
    @classmethod
5✔
226
    def should_add_emoji(cls) -> bool:
5✔
227
        """
228
        If last commit message is startswith emoji,
229
        add a ⬆️ flag at the prefix of bump up commit message.
230
        """
231
        try:
5✔
232
            first_char = cls.get_last_commit_message(raises=True)[0]
5✔
233
        except (IndexError, ShellCommandError):
5✔
234
            return False
5✔
235
        else:
236
            return emoji.is_emoji(first_char)
5✔
237

238
    @staticmethod
5✔
239
    def parse_filename() -> str:
5✔
240
        toml_text = Project.load_toml_text()
5✔
241
        context = tomllib.loads(toml_text)
5✔
242
        by_version_plugin = False
5✔
243
        try:
5✔
244
            ver = context["project"]["version"]
5✔
245
        except KeyError:
5✔
246
            pass
5✔
247
        else:
248
            if isinstance(ver, str):
5✔
249
                if ver in ("0", "0.0.0"):
5✔
250
                    by_version_plugin = True
5✔
251
                elif re.match(r"\d+\.\d+\.\d+", ver):
5✔
252
                    return TOML_FILE
5✔
253
        if not by_version_plugin:
5✔
254
            try:
5✔
255
                version_value = context["tool"]["poetry"]["version"]
5✔
256
            except KeyError:
5✔
257
                if not Project.manage_by_poetry():
5✔
258
                    # version = { source = "file", path = "fast_dev_cli/__init__.py" }
259
                    v_key = "version = "
5✔
260
                    p_key = 'path = "'
5✔
261
                    for line in toml_text.splitlines():
5✔
262
                        if not line.startswith(v_key):
5✔
263
                            continue
5✔
264
                        if p_key in (value := line.split(v_key, 1)[-1].split("#")[0]):
5✔
265
                            filename = value.split(p_key, 1)[-1].split('"')[0]
5✔
266
                            if Project.get_work_dir().joinpath(filename).exists():
5✔
267
                                return filename
5✔
268
            else:
269
                by_version_plugin = version_value in ("0", "0.0.0", "init")
5✔
270
        if by_version_plugin:
5✔
271
            try:
5✔
272
                package_item = context["tool"]["poetry"]["packages"]
5✔
273
            except KeyError:
5✔
274
                try:
5✔
275
                    project_name = context["project"]["name"]
5✔
276
                except KeyError:
5✔
277
                    packages = []
5✔
278
                else:
279
                    packages = [(poetry_module_name(project_name), "")]
5✔
280
            else:
281
                packages = [
5✔
282
                    (j, i.get("from", ""))
283
                    for i in package_item
284
                    if (j := i.get("include"))
285
                ]
286
            # In case of managed by `poetry-plugin-version`
287
            cwd = Path.cwd()
5✔
288
            pattern = re.compile(r"__version__\s*=\s*['\"]")
5✔
289
            ds: list[Path] = []
5✔
290
            for package_name, source_dir in packages:
5✔
291
                ds.append(cwd / package_name)
5✔
292
                ds.append(cwd / "src" / package_name)
5✔
293
                if source_dir and source_dir != "src":
5✔
294
                    ds.append(cwd / source_dir / package_name)
5✔
295
            module_name = poetry_module_name(cwd.name)
5✔
296
            ds.extend([cwd / module_name, cwd / "src" / module_name, cwd])
5✔
297
            for d in ds:
5✔
298
                init_file = d / "__init__.py"
5✔
299
                if init_file.exists() and pattern.search(init_file.read_text("utf8")):
5✔
300
                    break
5✔
301
            else:
302
                raise ParseError("Version file not found! Where are you now?")
5✔
303
            return os.path.relpath(init_file, cwd)
5✔
304

305
        return TOML_FILE
5✔
306

307
    def get_part(self, s: str) -> str:
5✔
308
        choices: dict[str, str] = {}
5✔
309
        for i, p in enumerate(self.PartChoices, 1):
5✔
310
            v = str(p)
5✔
311
            choices.update({str(i): v, v: v})
5✔
312
        try:
5✔
313
            return choices[s]
5✔
314
        except KeyError as e:
5✔
315
            echo(f"Invalid part: {s!r}")
5✔
316
            raise Exit(1) from e
5✔
317

318
    def gen(self: Self) -> str:
5✔
319
        _version = get_current_version()
5✔
320
        filename = self.filename
5✔
321
        echo(f"Current version(@{filename}): {_version}")
5✔
322
        if self.part:
5✔
323
            part = self.get_part(self.part)
5✔
324
        else:
325
            part = "patch"
5✔
326
            if a := input("Which one?").strip():
5✔
327
                part = self.get_part(a)
5✔
328
        self.part = part
5✔
329
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5✔
330
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
5✔
331
        if self.commit:
5✔
332
            if part != "patch":
5✔
333
                cmd += " --tag"
5✔
334
            cmd += " --commit"
5✔
335
            if self.should_add_emoji():
5✔
336
                cmd += " --message-emoji=1"
5✔
337
            if not load_bool("DONT_GIT_PUSH"):
5✔
338
                cmd += " && git push && git push --tags && git log -1"
5✔
339
        else:
340
            cmd += " --allow-dirty"
5✔
341
        return cmd
5✔
342

343
    def run(self: Self) -> None:
5✔
344
        super().run()
5✔
345
        if not self.commit and not self.dry:
5✔
346
            new_version = get_current_version(True)
5✔
347
            echo(new_version)
5✔
348
            if self.part != "patch":
5✔
349
                echo("You may want to pin tag by `fast tag`")
5✔
350

351

352
@cli.command()
5✔
353
def version() -> None:
5✔
354
    """Show the version of this tool"""
355
    echo(f"Fast Dev Cli version: {__version__}")
5✔
356

357

358
@cli.command(name="bump")
5✔
359
def bump_version(
5✔
360
    part: BumpUp.PartChoices,
361
    commit: bool = Option(
362
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
363
    ),
364
    dry: bool = DryOption,
365
) -> None:
366
    """Bump up version string in pyproject.toml"""
367
    return BumpUp(_ensure_bool(commit), getattr(part, "value", part), dry=dry).run()
5✔
368

369

370
def bump() -> None:
5✔
371
    part, commit = "", False
5✔
372
    if args := sys.argv[2:]:
5✔
373
        if "-c" in args or "--commit" in args:
5✔
374
            commit = True
5✔
375
        for a in args:
5✔
376
            if not a.startswith("-"):
5✔
377
                part = a
5✔
378
                break
5✔
379
    return BumpUp(commit, part, dry="--dry" in args).run()
5✔
380

381

382
class EnvError(Exception):
5✔
383
    """Raise when expected to be managed by poetry, but toml file not found."""
384

385

386
class Project:
5✔
387
    path_depth = 5
5✔
388

389
    @staticmethod
5✔
390
    def is_poetry_v2(text: str) -> bool:
5✔
391
        return 'build-backend = "poetry' in text
5✔
392

393
    @staticmethod
5✔
394
    def work_dir(name: str, parent: Path, depth: int, be_file=False) -> Path | None:
5✔
395
        for _ in range(depth):
5✔
396
            if (f := parent.joinpath(name)).exists():
5✔
397
                if be_file:
5✔
398
                    return f
5✔
399
                return parent
5✔
400
            parent = parent.parent
5✔
401
        return None
5✔
402

403
    @classmethod
5✔
404
    def get_work_dir(
5✔
405
        cls: type[Self],
406
        name=TOML_FILE,
407
        cwd: Path | None = None,
408
        allow_cwd=False,
409
        be_file=False,
410
    ) -> Path:
411
        cwd = cwd or Path.cwd()
5✔
412
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5✔
413
            return d
5✔
414
        if allow_cwd:
5✔
415
            return cls.get_root_dir(cwd)
5✔
416
        raise EnvError(f"{name} not found! Make sure this is a poetry project.")
5✔
417

418
    @classmethod
5✔
419
    def load_toml_text(cls: type[Self], name=TOML_FILE) -> str:
5✔
420
        toml_file = cls.get_work_dir(name, be_file=True)
5✔
421
        return toml_file.read_text("utf8")
5✔
422

423
    @classmethod
5✔
424
    def manage_by_poetry(cls: type[Self]) -> bool:
5✔
425
        return cls.get_manage_tool() == "poetry"
5✔
426

427
    @classmethod
5✔
428
    def get_manage_tool(cls: type[Self]) -> ToolName | None:
5✔
429
        try:
5✔
430
            text = cls.load_toml_text()
5✔
431
        except EnvError:
5✔
432
            pass
5✔
433
        else:
434
            for name in get_args(ToolName):
5✔
435
                if f"[tool.{name}]" in text:
5✔
436
                    return name
5✔
437
            # Poetry 2.0 default to not include the '[tool.poetry]' section
438
            if cls.is_poetry_v2(text):
5✔
439
                return "poetry"
5✔
440
        return None
5✔
441

442
    @staticmethod
5✔
443
    def python_exec_dir() -> Path:
5✔
444
        return Path(sys.executable).parent
5✔
445

446
    @classmethod
5✔
447
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5✔
448
        root = cwd or Path.cwd()
5✔
449
        venv_parent = cls.python_exec_dir().parent.parent
5✔
450
        if root.is_relative_to(venv_parent):
5✔
451
            root = venv_parent
5✔
452
        return root
5✔
453

454

455
class ParseError(Exception):
5✔
456
    """Raise this if parse dependence line error"""
457

458
    pass
5✔
459

460

461
class UpgradeDependencies(Project, DryRun):
5✔
462
    def __init__(self: Self, _exit=False, dry=False, tool: ToolName = "poetry") -> None:
5✔
463
        super().__init__(_exit, dry)
5✔
464
        self._tool = tool
5✔
465

466
    class DevFlag(StrEnum):
5✔
467
        new = "[tool.poetry.group.dev.dependencies]"
5✔
468
        old = "[tool.poetry.dev-dependencies]"
5✔
469

470
    @staticmethod
5✔
471
    def parse_value(version_info: str, key: str) -> str:
5✔
472
        """Pick out the value for key in version info.
473

474
        Example::
475
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
476
            >>> UpgradeDependencies.parse_value(s, 'extras')
477
            'all'
478
            >>> UpgradeDependencies.parse_value(s, 'optional')
479
            'true'
480
            >>> UpgradeDependencies.parse_value(s, 'version')
481
            '^0.9.0'
482
        """
483
        sep = key + " = "
5✔
484
        rest = version_info.split(sep, 1)[-1].strip(" =")
5✔
485
        if rest.startswith("["):
5✔
486
            rest = rest[1:].split("]")[0]
5✔
487
        elif rest.startswith('"'):
5✔
488
            rest = rest[1:].split('"')[0]
5✔
489
        else:
490
            rest = rest.split(",")[0].split("}")[0]
5✔
491
        return rest.strip().replace('"', "")
5✔
492

493
    @staticmethod
5✔
494
    def no_need_upgrade(version_info: str, line: str) -> bool:
5✔
495
        if (v := version_info.replace(" ", "")).startswith("{url="):
5✔
496
            echo(f"No need to upgrade for: {line}")
5✔
497
            return True
5✔
498
        if (f := "version=") in v:
5✔
499
            v = v.split(f)[1].strip('"').split('"')[0]
5✔
500
        if v == "*":
5✔
501
            echo(f"Skip wildcard line: {line}")
5✔
502
            return True
5✔
503
        elif v == "[":
5✔
504
            echo(f"Skip complex dependence: {line}")
5✔
505
            return True
5✔
506
        elif v.startswith(">") or v.startswith("<") or v[0].isdigit():
5✔
507
            echo(f"Ignore bigger/smaller/equal: {line}")
5✔
508
            return True
5✔
509
        return False
5✔
510

511
    @classmethod
5✔
512
    def build_args(
5✔
513
        cls: type[Self], package_lines: list[str]
514
    ) -> tuple[list[str], dict[str, list[str]]]:
515
        args: list[str] = []  # ['typer[all]', 'fastapi']
5✔
516
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
5✔
517
        for no, line in enumerate(package_lines, 1):
5✔
518
            if (
5✔
519
                not (m := line.strip())
520
                or m.startswith("#")
521
                or m == "]"
522
                or (m.startswith("{") and m.strip(",").endswith("}"))
523
            ):
524
                continue
5✔
525
            try:
5✔
526
                package, version_info = m.split("=", 1)
5✔
527
            except ValueError as e:
5✔
528
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
5✔
529
            if (package := package.strip()).lower() == "python":
5✔
530
                continue
5✔
531
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
5✔
532
                continue
5✔
533
            if (extras_tip := "extras") in version_info:
5✔
534
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
5✔
535
            item = f'"{package}@latest"'
5✔
536
            key = None
5✔
537
            if (pf := "platform") in version_info:
5✔
538
                platform = cls.parse_value(version_info, pf)
5✔
539
                key = f"--{pf}={platform}"
5✔
540
            if (sc := "source") in version_info:
5✔
541
                source = cls.parse_value(version_info, sc)
5✔
542
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
5✔
543
            if "optional = true" in version_info:
5✔
544
                key = ("" if key is None else (key + " ")) + "--optional"
5✔
545
            if key is not None:
5✔
546
                specials[key] = specials.get(key, []) + [item]
5✔
547
            else:
548
                args.append(item)
5✔
549
        return args, specials
5✔
550

551
    @classmethod
5✔
552
    def should_with_dev(cls: type[Self]) -> bool:
5✔
553
        text = cls.load_toml_text()
5✔
554
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
555

556
    @staticmethod
5✔
557
    def parse_item(toml_str) -> list[str]:
5✔
558
        lines: list[str] = []
5✔
559
        for line in toml_str.splitlines():
5✔
560
            if (line := line.strip()).startswith("["):
5✔
561
                if lines:
5✔
562
                    break
5✔
563
            elif line:
5✔
564
                lines.append(line)
5✔
565
        return lines
5✔
566

567
    @classmethod
5✔
568
    def get_args(
5✔
569
        cls: type[Self], toml_text: str | None = None
570
    ) -> tuple[list[str], list[str], list[list[str]], str]:
571
        if toml_text is None:
5✔
572
            toml_text = cls.load_toml_text()
5✔
573
        main_title = "[tool.poetry.dependencies]"
5✔
574
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
5✔
575
            toml_text
576
        ):
577
            raise EnvError(
5✔
578
                f"{main_title} not found! Make sure this is a poetry project."
579
            )
580
        text = toml_text.split(main_title)[-1]
5✔
581
        dev_flag = "--group dev"
5✔
582
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
5✔
583
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
5✔
584
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
5✔
585
            dev_flag = "--dev"
5✔
586
        others: list[list[str]] = []
5✔
587
        try:
5✔
588
            main_toml, dev_toml = text.split(dev_title)
5✔
589
        except ValueError:
5✔
590
            dev_toml = ""
5✔
591
            main_toml = text
5✔
592
        mains = [] if no_main_deps else cls.parse_item(main_toml)
5✔
593
        devs = cls.parse_item(dev_toml)
5✔
594
        prod_packs, specials = cls.build_args(mains)
5✔
595
        if specials:
5✔
596
            others.extend([[k] + v for k, v in specials.items()])
5✔
597
        dev_packs, specials = cls.build_args(devs)
5✔
598
        if specials:
5✔
599
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
5✔
600
        return prod_packs, dev_packs, others, dev_flag
5✔
601

602
    @classmethod
5✔
603
    def gen_cmd(cls: type[Self]) -> str:
5✔
604
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
605
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
606

607
    @staticmethod
5✔
608
    def to_cmd(
5✔
609
        main_args: list[str],
610
        dev_args: list[str],
611
        others: list[list[str]],
612
        dev_flags: str,
613
    ) -> str:
614
        command = "poetry add "
5✔
615
        _upgrade = ""
5✔
616
        if main_args:
5✔
617
            _upgrade = command + " ".join(main_args)
5✔
618
        if dev_args:
5✔
619
            if _upgrade:
5✔
620
                _upgrade += " && "
5✔
621
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5✔
622
        for single in others:
5✔
623
            _upgrade += f" && poetry add {' '.join(single)}"
5✔
624
        return _upgrade
5✔
625

626
    def gen(self: Self) -> str:
5✔
627
        if self._tool == "uv":
5✔
628
            return "uv lock --upgrade --verbose && uv sync --frozen"
×
629
        elif self._tool == "pdm":
5✔
630
            return "pdm update --verbose && pdm install"
×
631
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
632

633

634
@cli.command()
5✔
635
def upgrade(
5✔
636
    tool: str = ToolOption,
637
    dry: bool = DryOption,
638
) -> None:
639
    """Upgrade dependencies in pyproject.toml to latest versions"""
640
    if not (tool := _ensure_str(tool)) or tool == ToolOption.default:
5✔
641
        tool = Project.get_manage_tool() or "uv"
5✔
642
    if tool in get_args(ToolName):
5✔
643
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
5✔
644
    else:
645
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
5✔
646
        raise typer.Exit(1)
5✔
647

648

649
class GitTag(DryRun):
5✔
650
    def __init__(self: Self, message: str, dry: bool) -> None:
5✔
651
        self.message = message
5✔
652
        super().__init__(dry=dry)
5✔
653

654
    @staticmethod
5✔
655
    def has_v_prefix() -> bool:
5✔
656
        return "v" in capture_cmd_output("git tag")
5✔
657

658
    def should_push(self: Self) -> bool:
5✔
659
        return "git push" in self.git_status
5✔
660

661
    def gen(self: Self) -> str:
5✔
662
        _version = get_current_version(verbose=False)
5✔
663
        if self.has_v_prefix():
5✔
664
            # Add `v` at prefix to compare with bumpversion tool
665
            _version = "v" + _version
5✔
666
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
5✔
667
        if self.should_push():
5✔
668
            cmd += " && git push"
5✔
669
        return cmd
5✔
670

671
    @cached_property
5✔
672
    def git_status(self: Self) -> str:
5✔
673
        return capture_cmd_output("git status")
5✔
674

675
    def mark_tag(self: Self) -> bool:
5✔
676
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5✔
677
            "无文件要提交,干净的工作区" not in self.git_status
678
        ):
679
            run_and_echo("git status")
5✔
680
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5✔
681
            return False
5✔
682
        return bool(super().run())
5✔
683

684
    def run(self: Self) -> None:
5✔
685
        if self.mark_tag() and not self.dry:
5✔
686
            echo("You may want to publish package:\n poetry publish --build")
5✔
687

688

689
@cli.command()
5✔
690
def tag(
5✔
691
    message: str = Option("", "-m", "--message"),
692
    dry: bool = DryOption,
693
) -> None:
694
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
695
    GitTag(message, dry=dry).run()
5✔
696

697

698
class LintCode(DryRun):
5✔
699
    def __init__(
5✔
700
        self: Self,
701
        args,
702
        check_only=False,
703
        _exit=False,
704
        dry=False,
705
        bandit=False,
706
        skip_mypy=False,
707
        dmypy=False,
708
        tool: str = ToolOption.default,
709
    ) -> None:
710
        self.args = args
5✔
711
        self.check_only = check_only
5✔
712
        self._bandit = bandit
5✔
713
        self._skip_mypy = skip_mypy
5✔
714
        self._use_dmypy = dmypy
5✔
715
        self._tool = tool
5✔
716
        super().__init__(_exit, dry)
5✔
717

718
    @staticmethod
5✔
719
    def check_lint_tool_installed() -> bool:
5✔
720
        return check_call("ruff --version")
5✔
721

722
    @staticmethod
5✔
723
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy=False) -> bool:
5✔
724
        return (
5✔
725
            paths == "."
726
            and any(t.startswith("mypy") for t in tools)
727
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
728
        )
729

730
    @staticmethod
5✔
731
    def get_package_name() -> str:
5✔
732
        root = Project.get_work_dir(allow_cwd=True)
5✔
733
        module_name = root.name.replace("-", "_").replace(" ", "_")
5✔
734
        package_maybe = (module_name, "src")
5✔
735
        for name in package_maybe:
5✔
736
            if root.joinpath(name).is_dir():
5✔
737
                return name
5✔
738
        return "."
5✔
739

740
    @classmethod
5✔
741
    def to_cmd(
5✔
742
        cls: type[Self],
743
        paths: str = ".",
744
        check_only: bool = False,
745
        bandit: bool = False,
746
        skip_mypy: bool = False,
747
        use_dmypy: bool = False,
748
        tool: str = ToolOption.default,
749
    ) -> str:
750
        if paths != "." and all(i.endswith(".html") for i in paths.split()):
5✔
751
            return f"prettier -w {paths}"
5✔
752
        cmd = ""
5✔
753
        tools = ["ruff format", "ruff check --extend-select=I,B,SIM --fix", "mypy"]
5✔
754
        if check_only:
5✔
755
            tools[0] += " --check"
5✔
756
        if check_only or load_bool("NO_FIX"):
5✔
757
            tools[1] = tools[1].replace(" --fix", "")
5✔
758
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
5✔
759
            # Sometimes mypy is too slow
760
            tools = tools[:-1]
5✔
761
        elif load_bool("IGNORE_MISSING_IMPORTS"):
5✔
762
            tools[-1] += " --ignore-missing-imports"
5✔
763
        lint_them = " && ".join(
5✔
764
            "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
765
        )
766
        prefix = ""
5✔
767
        should_run_by_tool = False
5✔
768
        if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(".local/bin"):
5✔
769
            if not cls.check_lint_tool_installed():
5✔
770
                should_run_by_tool = True
5✔
771
                if check_call('python -c "import fast_dev_cli"'):
5✔
772
                    command = 'python -m pip install -U "fast-dev-cli"'
5✔
773
                    tip = "You may need to run following command to install lint tools:"
5✔
774
                    secho(f"{tip}\n\n  {command}\n", fg="yellow")
5✔
775
        else:
776
            should_run_by_tool = True
5✔
777
        if should_run_by_tool and tool:
5✔
778
            if tool == ToolOption.default:
5✔
779
                tool = Project.get_manage_tool() or ""
5✔
780
            if tool:
5✔
781
                prefix = tool + " run "
5✔
782
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
5✔
783
            tools[-1] = "dmypy run"
5✔
784
        cmd += lint_them.format(prefix, paths, *tools)
5✔
785
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
5✔
786
            command = prefix + "bandit"
5✔
787
            if Path("pyproject.toml").exists():
5✔
788
                toml_text = Project.load_toml_text()
5✔
789
                if "[tool.bandit" in toml_text:
5✔
790
                    command += " -c pyproject.toml"
5✔
791
            if paths == "." and " -c " not in command:
5✔
792
                paths = cls.get_package_name()
5✔
793
            command += f" -r {paths}"
5✔
794
            cmd += " && " + command
5✔
795
        return cmd
5✔
796

797
    def gen(self: Self) -> str:
5✔
798
        paths = " ".join(map(str, self.args)) if self.args else "."
5✔
799
        return self.to_cmd(
5✔
800
            paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy
801
        )
802

803

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

807

808
def lint(
5✔
809
    files=None,
810
    dry=False,
811
    bandit=False,
812
    skip_mypy=False,
813
    dmypy=False,
814
    tool=ToolOption.default,
815
) -> None:
816
    if files is None:
5✔
817
        files = parse_files(sys.argv[1:])
5✔
818
    if files and files[0] == "lint":
5✔
819
        files = files[1:]
5✔
820
    LintCode(
5✔
821
        files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy, tool=tool
822
    ).run()
823

824

825
def check(
5✔
826
    files=None,
827
    dry=False,
828
    bandit=False,
829
    skip_mypy=False,
830
    dmypy=False,
831
    tool=ToolOption.default,
832
) -> None:
833
    LintCode(
5✔
834
        files,
835
        check_only=True,
836
        _exit=True,
837
        dry=dry,
838
        bandit=bandit,
839
        skip_mypy=skip_mypy,
840
        dmypy=dmypy,
841
        tool=tool,
842
    ).run()
843

844

845
@cli.command(name="lint")
5✔
846
def make_style(
5✔
847
    files: Optional[list[str]] = typer.Argument(default=None),  # noqa:B008
848
    check_only: bool = Option(False, "--check-only", "-c"),
849
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
850
    skip_mypy: bool = Option(False, "--skip-mypy"),
851
    use_dmypy: bool = Option(
852
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
853
    ),
854
    tool: str = ToolOption,
855
    dry: bool = DryOption,
856
) -> None:
857
    """Run: ruff check/format to reformat code and then mypy to check"""
858
    if getattr(files, "default", files) is None:
5✔
859
        files = ["."]
5✔
860
    elif isinstance(files, str):
5✔
861
        files = [files]
5✔
862
    skip = _ensure_bool(skip_mypy)
5✔
863
    dmypy = _ensure_bool(use_dmypy)
5✔
864
    bandit = _ensure_bool(bandit)
5✔
865
    tool = _ensure_str(tool)
5✔
866
    if _ensure_bool(check_only):
5✔
867
        check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
5✔
868
    else:
869
        lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
5✔
870

871

872
@cli.command(name="check")
5✔
873
def only_check(
5✔
874
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
875
    skip_mypy: bool = Option(False, "--skip-mypy"),
876
    dry: bool = DryOption,
877
) -> None:
878
    """Check code style without reformat"""
879
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
5✔
880

881

882
class Sync(DryRun):
5✔
883
    def __init__(self: Self, filename: str, extras: str, save: bool, dry=False) -> None:
5✔
884
        self.filename = filename
5✔
885
        self.extras = extras
5✔
886
        self._save = save
5✔
887
        super().__init__(dry=dry)
5✔
888

889
    def gen(self) -> str:
5✔
890
        extras, save = self.extras, self._save
5✔
891
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
892
        if not (tool := Project.get_manage_tool()):
5✔
893
            if should_remove or not is_venv():
5✔
894
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
895
            return f"python -m pip install -r {self.filename}"
5✔
896
        prefix = "" if is_venv() else f"{tool} run "
5✔
897
        ensurepip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
898
        if tool == "uv":
5✔
899
            export_cmd = "uv export --no-hashes --all-extras --frozen"
5✔
900
            if check_call(prefix + "python -m pip --version"):
5✔
901
                ensurepip = ""
5✔
902
        elif tool in ("poetry", "pdm"):
5✔
903
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
904
            if tool == "poetry":
5✔
905
                ensurepip = ""
5✔
906
                if not UpgradeDependencies.should_with_dev():
5✔
907
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
908
                if extras and isinstance(extras, (str, list)):
5✔
909
                    export_cmd += f" --{extras=}".replace("'", '"')
5✔
910
            elif check_call(prefix + "python -m pip --version"):
5✔
911
                ensurepip = ""
5✔
912
        install_cmd = (
5✔
913
            f"{{2}} -o {{0}} &&{ensurepip} {{1}}python -m pip install -r {{0}}"
914
        )
915
        if should_remove and not save:
5✔
916
            install_cmd += " && rm -f {0}"
5✔
917
        return install_cmd.format(self.filename, prefix, export_cmd)
5✔
918

919

920
@cli.command()
5✔
921
def sync(
5✔
922
    filename="dev_requirements.txt",
923
    extras: str = Option("", "--extras", "-E"),
924
    save: bool = Option(
925
        False, "--save", "-s", help="Whether save the requirement file"
926
    ),
927
    dry: bool = DryOption,
928
) -> None:
929
    """Export dependencies by poetry to a txt file then install by pip."""
930
    Sync(filename, extras, save, dry=dry).run()
5✔
931

932

933
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
934
    for name in ("test.sh", "test.py"):
5✔
935
        if (file := path / name).exists():
5✔
936
            return file
5✔
937
    return None
5✔
938

939

940
def test(dry: bool, ignore_script=False) -> None:
5✔
941
    cwd = Path.cwd()
5✔
942
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
943
    script_dir = root / "scripts"
5✔
944
    if not _ensure_bool(ignore_script) and (
5✔
945
        test_script := _should_run_test_script(script_dir)
946
    ):
947
        cmd = f"{os.path.relpath(test_script, root)}"
5✔
948
        if cwd != root:
5✔
949
            cmd = f"cd {root} && " + cmd
5✔
950
    else:
951
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
952
        if not is_venv() or not check_call("coverage --version"):
5✔
953
            sep = " && "
5✔
954
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
955
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
956
    exit_if_run_failed(cmd, dry=dry)
5✔
957

958

959
@cli.command(name="test")
5✔
960
def coverage_test(
5✔
961
    dry: bool = DryOption,
962
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
963
) -> None:
964
    """Run unittest by pytest and report coverage"""
965
    return test(dry, ignore_script)
5✔
966

967

968
class Publish:
5✔
969
    class CommandEnum(StrEnum):
5✔
970
        poetry = "poetry publish --build"
5✔
971
        pdm = "pdm publish"
5✔
972
        uv = "uv build && uv publish"
5✔
973
        twine = "python -m build && twine upload"
5✔
974

975
    @classmethod
5✔
976
    def gen(cls) -> str:
5✔
977
        if tool := Project.get_manage_tool():
5✔
978
            return cls.CommandEnum[tool]
5✔
979
        return cls.CommandEnum.twine
5✔
980

981

982
@cli.command()
5✔
983
def upload(
5✔
984
    dry: bool = DryOption,
985
) -> None:
986
    """Shortcut for package publish"""
987
    cmd = Publish.gen()
5✔
988
    exit_if_run_failed(cmd, dry=dry)
5✔
989

990

991
def dev(
5✔
992
    port: int | None | OptionInfo,
993
    host: str | None | OptionInfo,
994
    file: str | None | ArgumentInfo = None,
995
    dry=False,
996
) -> None:
997
    cmd = "fastapi dev"
5✔
998
    no_port_yet = True
5✔
999
    if file is not None:
5✔
1000
        try:
5✔
1001
            port = int(str(file))  # type:ignore[arg-type]
5✔
1002
        except ValueError:
5✔
1003
            cmd += f" {file}"
5✔
1004
        else:
1005
            if port != 8000:
5✔
1006
                cmd += f" --port={port}"
5✔
1007
                no_port_yet = False
5✔
1008
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
1009
        cmd += f" --port={port}"
5✔
1010
    if (host := getattr(host, "default", host)) and host not in (
5✔
1011
        "localhost",
1012
        "127.0.0.1",
1013
    ):
1014
        cmd += f" --host={host}"
5✔
1015
    exit_if_run_failed(cmd, dry=dry)
5✔
1016

1017

1018
@cli.command(name="dev")
5✔
1019
def runserver(
5✔
1020
    file_or_port: Optional[str] = typer.Argument(default=None),
1021
    port: Optional[int] = Option(None, "-p", "--port"),
1022
    host: Optional[str] = Option(None, "-h", "--host"),
1023
    dry: bool = DryOption,
1024
) -> None:
1025
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1026
    if getattr(file_or_port, "default", file_or_port):
5✔
1027
        dev(port, host, file=file_or_port, dry=dry)
5✔
1028
    else:
1029
        dev(port, host, dry=dry)
5✔
1030

1031

1032
def main() -> None:
5✔
1033
    cli()
5✔
1034

1035

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

© 2026 Coveralls, Inc