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

waketzheng / fast-dev-cli / 14508006821

17 Apr 2025 04:24AM UTC coverage: 98.815% (+0.5%) from 98.328%
14508006821

push

github

waketzheng
feat: support --tool for upgrade and lint

21 of 23 new or added lines in 1 file covered. (91.3%)

6 existing lines in 1 file now uncovered.

667 of 675 relevant lines covered (98.81%)

4.94 hits per line

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

98.81
/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, 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
    class DevFlag(StrEnum):
5✔
463
        new = "[tool.poetry.group.dev.dependencies]"
5✔
464
        old = "[tool.poetry.dev-dependencies]"
5✔
465

466
    @staticmethod
5✔
467
    def parse_value(version_info: str, key: str) -> str:
5✔
468
        """Pick out the value for key in version info.
469

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

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

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

547
    @classmethod
5✔
548
    def should_with_dev(cls: type[Self]) -> bool:
5✔
549
        text = cls.load_toml_text()
5✔
550
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
551

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

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

598
    @classmethod
5✔
599
    def gen_cmd(cls: type[Self]) -> str:
5✔
600
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
601
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
602

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

622
    def gen(self: Self) -> str:
5✔
623
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
624

625

626
@cli.command()
5✔
627
def upgrade(
5✔
628
    tool: str = ToolOption,
629
    dry: bool = DryOption,
630
) -> None:
631
    """Upgrade dependencies in pyproject.toml to latest versions"""
632
    if not (tool := _ensure_str(tool)) or tool == "auto":
5✔
633
        tool = Project.get_manage_tool() or "uv"
5✔
634
    if tool == "uv":
5✔
UNCOV
635
        exit_if_run_failed("uv lock --upgrade && uv sync", dry=dry)
×
636
    elif tool == "pdm":
5✔
UNCOV
637
        exit_if_run_failed("pdm update && pdm install", dry=dry)
×
638
    elif tool == "poetry":
5✔
639
        UpgradeDependencies(dry=dry).run()
5✔
640
    else:
NEW
641
        secho("Unknown tool {tool!r}", fg=typer.colors.YELLOW)
×
NEW
642
        raise typer.Exit(1)
×
643

644

645
class GitTag(DryRun):
5✔
646
    def __init__(self: Self, message: str, dry: bool) -> None:
5✔
647
        self.message = message
5✔
648
        super().__init__(dry=dry)
5✔
649

650
    @staticmethod
5✔
651
    def has_v_prefix() -> bool:
5✔
652
        return "v" in capture_cmd_output("git tag")
5✔
653

654
    def should_push(self: Self) -> bool:
5✔
655
        return "git push" in self.git_status
5✔
656

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

667
    @cached_property
5✔
668
    def git_status(self: Self) -> str:
5✔
669
        return capture_cmd_output("git status")
5✔
670

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

680
    def run(self: Self) -> None:
5✔
681
        if self.mark_tag() and not self.dry:
5✔
682
            echo("You may want to publish package:\n poetry publish --build")
5✔
683

684

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

693

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

714
    @staticmethod
5✔
715
    def check_lint_tool_installed() -> bool:
5✔
716
        return check_call("ruff --version")
5✔
717

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

726
    @staticmethod
5✔
727
    def get_package_name() -> str:
5✔
728
        root = Project.get_work_dir(allow_cwd=True)
5✔
729
        package_maybe = (root.name.replace("-", "_"), "src")
5✔
730
        for name in package_maybe:
5✔
731
            if root.joinpath(name).is_dir():
5✔
UNCOV
732
                return name
×
733
        return "."
5✔
734

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

792
    def gen(self: Self) -> str:
5✔
793
        paths = " ".join(map(str, self.args)) if self.args else "."
5✔
794
        return self.to_cmd(
5✔
795
            paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy
796
        )
797

798

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

802

803
def lint(
5✔
804
    files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False, tool="auto"
805
) -> None:
806
    if files is None:
5✔
807
        files = parse_files(sys.argv[1:])
5✔
808
    if files and files[0] == "lint":
5✔
809
        files = files[1:]
5✔
810
    LintCode(
5✔
811
        files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy, tool=tool
812
    ).run()
813

814

815
def check(
5✔
816
    files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False, tool="auto"
817
) -> None:
818
    LintCode(
5✔
819
        files,
820
        check_only=True,
821
        _exit=True,
822
        dry=dry,
823
        bandit=bandit,
824
        skip_mypy=skip_mypy,
825
        dmypy=dmypy,
826
        tool=tool,
827
    ).run()
828

829

830
@cli.command(name="lint")
5✔
831
def make_style(
5✔
832
    files: Optional[list[str]] = typer.Argument(default=None),  # noqa:B008
833
    check_only: bool = Option(False, "--check-only", "-c"),
834
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
835
    skip_mypy: bool = Option(False, "--skip-mypy"),
836
    use_dmypy: bool = Option(
837
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
838
    ),
839
    tool: str = ToolOption,
840
    dry: bool = DryOption,
841
) -> None:
842
    """Run: ruff check/format to reformat code and then mypy to check"""
843
    if getattr(files, "default", files) is None:
5✔
844
        files = ["."]
5✔
845
    elif isinstance(files, str):
5✔
846
        files = [files]
5✔
847
    skip = _ensure_bool(skip_mypy)
5✔
848
    dmypy = _ensure_bool(use_dmypy)
5✔
849
    bandit = _ensure_bool(bandit)
5✔
850
    tool = _ensure_str(tool)
5✔
851
    if _ensure_bool(check_only):
5✔
852
        check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
5✔
853
    else:
854
        lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
5✔
855

856

857
@cli.command(name="check")
5✔
858
def only_check(
5✔
859
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
860
    skip_mypy: bool = Option(False, "--skip-mypy"),
861
    dry: bool = DryOption,
862
) -> None:
863
    """Check code style without reformat"""
864
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
5✔
865

866

867
class Sync(DryRun):
5✔
868
    def __init__(self: Self, filename: str, extras: str, save: bool, dry=False) -> None:
5✔
869
        self.filename = filename
5✔
870
        self.extras = extras
5✔
871
        self._save = save
5✔
872
        super().__init__(dry=dry)
5✔
873

874
    def gen(self) -> str:
5✔
875
        extras, save = self.extras, self._save
5✔
876
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
877
        if not (tool := Project.get_manage_tool()):
5✔
878
            if should_remove or not is_venv():
5✔
879
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
880
            return f"python -m pip install -r {self.filename}"
5✔
881
        prefix = "" if is_venv() else f"{tool} run "
5✔
882
        ensurepip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
883
        if tool == "uv":
5✔
884
            export_cmd = "uv export --no-hashes --all-extras --frozen"
5✔
885
            if check_call(prefix + "python -m pip --version"):
5✔
886
                ensurepip = ""
5✔
887
        elif tool in ("poetry", "pdm"):
5✔
888
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
889
            if tool == "poetry":
5✔
890
                ensurepip = ""
5✔
891
                if not UpgradeDependencies.should_with_dev():
5✔
892
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
893
                if extras and isinstance(extras, (str, list)):
5✔
894
                    export_cmd += f" --{extras=}".replace("'", '"')
5✔
895
            elif check_call(prefix + "python -m pip --version"):
5✔
896
                ensurepip = ""
5✔
897
        install_cmd = (
5✔
898
            f"{{2}} -o {{0}} &&{ensurepip} {{1}}python -m pip install -r {{0}}"
899
        )
900
        if should_remove and not save:
5✔
901
            install_cmd += " && rm -f {0}"
5✔
902
        return install_cmd.format(self.filename, prefix, export_cmd)
5✔
903

904

905
@cli.command()
5✔
906
def sync(
5✔
907
    filename="dev_requirements.txt",
908
    extras: str = Option("", "--extras", "-E"),
909
    save: bool = Option(
910
        False, "--save", "-s", help="Whether save the requirement file"
911
    ),
912
    dry: bool = DryOption,
913
) -> None:
914
    """Export dependencies by poetry to a txt file then install by pip."""
915
    Sync(filename, extras, save, dry=dry).run()
5✔
916

917

918
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
919
    for name in ("test.sh", "test.py"):
5✔
920
        if (file := path / name).exists():
5✔
921
            return file
5✔
922
    return None
5✔
923

924

925
def test(dry: bool, ignore_script=False) -> None:
5✔
926
    cwd = Path.cwd()
5✔
927
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
928
    script_dir = root / "scripts"
5✔
929
    if not _ensure_bool(ignore_script) and (
5✔
930
        test_script := _should_run_test_script(script_dir)
931
    ):
932
        cmd = f"{os.path.relpath(test_script, root)}"
5✔
933
        if cwd != root:
5✔
934
            cmd = f"cd {root} && " + cmd
5✔
935
    else:
936
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
937
        if not is_venv() or not check_call("coverage --version"):
5✔
938
            sep = " && "
5✔
939
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
940
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
941
    exit_if_run_failed(cmd, dry=dry)
5✔
942

943

944
@cli.command(name="test")
5✔
945
def coverage_test(
5✔
946
    dry: bool = DryOption,
947
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
948
) -> None:
949
    """Run unittest by pytest and report coverage"""
950
    return test(dry, ignore_script)
5✔
951

952

953
class Publish:
5✔
954
    class CommandEnum(StrEnum):
5✔
955
        poetry = "poetry publish --build"
5✔
956
        pdm = "pdm publish"
5✔
957
        uv = "uv build && uv publish"
5✔
958
        twine = "python -m build && twine upload"
5✔
959

960
    @classmethod
5✔
961
    def gen(cls) -> str:
5✔
962
        if tool := Project.get_manage_tool():
5✔
963
            return cls.CommandEnum[tool]
5✔
964
        return cls.CommandEnum.twine
5✔
965

966

967
@cli.command()
5✔
968
def upload(
5✔
969
    dry: bool = DryOption,
970
) -> None:
971
    """Shortcut for package publish"""
972
    cmd = Publish.gen()
5✔
973
    exit_if_run_failed(cmd, dry=dry)
5✔
974

975

976
def dev(
5✔
977
    port: int | None | OptionInfo,
978
    host: str | None | OptionInfo,
979
    file: str | None | ArgumentInfo = None,
980
    dry=False,
981
) -> None:
982
    cmd = "fastapi dev"
5✔
983
    no_port_yet = True
5✔
984
    if file is not None:
5✔
985
        try:
5✔
986
            port = int(str(file))  # type:ignore[arg-type]
5✔
987
        except ValueError:
5✔
988
            cmd += f" {file}"
5✔
989
        else:
990
            if port != 8000:
5✔
991
                cmd += f" --port={port}"
5✔
992
                no_port_yet = False
5✔
993
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
994
        cmd += f" --port={port}"
5✔
995
    if (host := getattr(host, "default", host)) and host not in (
5✔
996
        "localhost",
997
        "127.0.0.1",
998
    ):
999
        cmd += f" --host={host}"
5✔
1000
    exit_if_run_failed(cmd, dry=dry)
5✔
1001

1002

1003
@cli.command(name="dev")
5✔
1004
def runserver(
5✔
1005
    file_or_port: Optional[str] = typer.Argument(default=None),
1006
    port: Optional[int] = Option(None, "-p", "--port"),
1007
    host: Optional[str] = Option(None, "-h", "--host"),
1008
    dry: bool = DryOption,
1009
) -> None:
1010
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1011
    if getattr(file_or_port, "default", file_or_port):
5✔
1012
        dev(port, host, file=file_or_port, dry=dry)
5✔
1013
    else:
1014
        dev(port, host, dry=dry)
5✔
1015

1016

1017
def main() -> None:
5✔
1018
    cli()
5✔
1019

1020

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