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

waketzheng / fast-dev-cli / 15464176569

05 Jun 2025 10:00AM UTC coverage: 97.29% (-2.7%) from 100.0%
15464176569

push

github

waketzheng
Merge branch 'main' into dev

1 of 1 new or added line in 1 file covered. (100.0%)

19 existing lines in 1 file now uncovered.

682 of 701 relevant lines covered (97.29%)

5.84 hits per line

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

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

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

21
import emoji
6✔
22
import typer
6✔
23
from typer import Exit, Option, echo, secho
6✔
24
from typer.models import ArgumentInfo, OptionInfo
6✔
25

26
try:
6✔
27
    from . import __version__
6✔
28
except ImportError:  # pragma: no cover
29
    from importlib import import_module as _import  # For local unittest
30

31
    __version__ = _import(Path(__file__).parent.name).__version__
32

33
if sys.version_info >= (3, 11):  # pragma: no cover
34
    from enum import StrEnum
35
    from typing import Self
36

37
    import tomllib
38
else:  # pragma: no cover
39
    from enum import Enum
40

41
    import tomli as tomllib
42
    from typing_extensions import Self
43

44
    class StrEnum(str, Enum):
45
        __str__ = str.__str__
46

47

48
cli = typer.Typer()
6✔
49
DryOption = Option(False, "--dry", help="Only print, not really run shell command")
6✔
50
TOML_FILE = "pyproject.toml"
6✔
51
ToolName = Literal["poetry", "pdm", "uv"]
6✔
52
ToolOption = Option(
6✔
53
    "auto", "--tool", help="Explicit declare manage tool (default to auto detect)"
54
)
55

56

57
class ShellCommandError(Exception): ...
6✔
58

59

60
def poetry_module_name(name: str) -> str:
6✔
61
    """Get module name that generated by `poetry new`"""
62
    from packaging.utils import canonicalize_name
6✔
63

64
    return canonicalize_name(name).replace("-", "_").replace(" ", "_")
6✔
65

66

67
def load_bool(name: str, default: bool = False) -> bool:
6✔
68
    if not (v := os.getenv(name)):
6✔
69
        return default
6✔
70
    if (lower := v.lower()) in ("0", "false", "f", "off", "no", "n"):
6✔
71
        return False
6✔
72
    elif lower in ("1", "true", "t", "on", "yes", "y"):
6✔
73
        return True
6✔
74
    secho(f"WARNING: can not convert value({v!r}) of {name} to bool!")
6✔
75
    return default
6✔
76

77

78
def is_venv() -> bool:
6✔
79
    """Whether in a virtual environment(also work for poetry)"""
80
    return hasattr(sys, "real_prefix") or (
6✔
81
        hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
82
    )
83

84

85
def _run_shell(cmd: list[str] | str, **kw: Any) -> subprocess.CompletedProcess[str]:
6✔
86
    if isinstance(cmd, str):
6✔
87
        kw.setdefault("shell", True)
6✔
88
    return subprocess.run(cmd, **kw)  # nosec:B603
6✔
89

90

91
def run_and_echo(
6✔
92
    cmd: str, *, dry: bool = False, verbose: bool = True, **kw: Any
93
) -> int:
94
    """Run shell command with subprocess and print it"""
95
    if verbose:
6✔
96
        echo(f"--> {cmd}")
6✔
97
    if dry:
6✔
98
        return 0
6✔
99
    return _run_shell(cmd, **kw).returncode
6✔
100

101

102
def check_call(cmd: str) -> bool:
6✔
103
    r = _run_shell(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
6✔
104
    return r.returncode == 0
6✔
105

106

107
def capture_cmd_output(
6✔
108
    command: list[str] | str, *, raises: bool = False, **kw: Any
109
) -> str:
110
    if isinstance(command, str) and not kw.get("shell"):
6✔
111
        command = shlex.split(command)
6✔
112
    r = _run_shell(command, capture_output=True, encoding="utf-8", **kw)
6✔
113
    if raises and r.returncode != 0:
6✔
114
        raise ShellCommandError(r.stderr)
6✔
115
    return r.stdout.strip() or r.stderr
6✔
116

117

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

121

122
def read_version_from_file(
6✔
123
    package_name: str, work_dir: Path | None = None, toml_text: str | None = None
124
) -> str:
125
    if not package_name and toml_text:
6✔
126
        pattern = re.compile(r"version\s*=")
6✔
127
        for line in toml_text.splitlines():
6✔
128
            if pattern.match(line):
6✔
129
                return _parse_version(line, pattern)
6✔
130
    version_file = BumpUp.parse_filename(toml_text, work_dir, package_name)
6✔
131
    if version_file == TOML_FILE:
6✔
132
        if toml_text is None:
6✔
133
            toml_text = Project.load_toml_text()
6✔
134
        context = tomllib.loads(toml_text)
6✔
135
        with contextlib.suppress(KeyError):
6✔
136
            return context["project"]["version"]
6✔
137
        with contextlib.suppress(KeyError):  # Poetry V1
6✔
138
            return context["tool"]["poetry"]["version"]
6✔
139
        secho(f"WARNING: can not find 'version' item in {version_file}!")
6✔
140
        return "0.0.0"
6✔
141
    pattern = re.compile(r"__version__\s*=")
6✔
142
    for line in Path(version_file).read_text("utf-8").splitlines():
6✔
143
        if pattern.match(line):
6✔
144
            return _parse_version(line, pattern)
6✔
145
    # TODO: remove or refactor the following lines.
UNCOV
146
    if work_dir is None:
×
UNCOV
147
        work_dir = Project.get_work_dir()
×
UNCOV
148
    package_dir = work_dir / package_name
×
UNCOV
149
    if (
×
150
        not (init_file := package_dir / "__init__.py").exists()
151
        and not (init_file := work_dir / "src" / package_name / init_file.name).exists()
152
        and not (init_file := work_dir / "app" / init_file.name).exists()
153
    ):
UNCOV
154
        secho("WARNING: __init__.py file does not exist!")
×
UNCOV
155
        return "0.0.0"
×
156

UNCOV
157
    pattern = re.compile(r"__version__\s*=")
×
UNCOV
158
    for line in init_file.read_text("utf-8").splitlines():
×
UNCOV
159
        if pattern.match(line):
×
UNCOV
160
            return _parse_version(line, pattern)
×
UNCOV
161
    secho(f"WARNING: can not find '__version__' var in {init_file}!")
×
UNCOV
162
    return "0.0.0"
×
163

164

165
def get_current_version(
6✔
166
    verbose: bool = False,
167
    is_poetry: bool | None = None,
168
    package_name: str | None = None,
169
) -> str:
170
    if is_poetry is None:
6✔
171
        is_poetry = Project.manage_by_poetry()
6✔
172
    if not is_poetry:
6✔
173
        work_dir = None
6✔
174
        if package_name is None:
6✔
175
            work_dir = Project.get_work_dir()
6✔
176
            package_name = re.sub(r"[- ]", "_", work_dir.name)
6✔
177
        try:
6✔
178
            return importlib_metadata.version(package_name)
6✔
179
        except importlib_metadata.PackageNotFoundError:
6✔
180
            return read_version_from_file(package_name, work_dir)
6✔
181

182
    cmd = ["poetry", "version", "-s"]
6✔
183
    if verbose:
6✔
184
        echo(f"--> {' '.join(cmd)}")
6✔
185
    if out := capture_cmd_output(cmd, raises=True):
6✔
186
        out = out.splitlines()[-1].strip().split()[-1]
6✔
187
    return out
6✔
188

189

190
def _ensure_bool(value: bool | OptionInfo) -> bool:
6✔
191
    if not isinstance(value, bool):
6✔
192
        value = getattr(value, "default", False)
6✔
193
    return value
6✔
194

195

196
def _ensure_str(value: str | OptionInfo) -> str:
6✔
197
    if not isinstance(value, str):
6✔
198
        value = getattr(value, "default", "")
6✔
199
    return value
6✔
200

201

202
def exit_if_run_failed(
6✔
203
    cmd: str,
204
    env: dict[str, str] | None = None,
205
    _exit: bool = False,
206
    dry: bool = False,
207
    **kw: Any,
208
) -> subprocess.CompletedProcess[str]:
209
    run_and_echo(cmd, dry=True)
6✔
210
    if _ensure_bool(dry):
6✔
211
        return subprocess.CompletedProcess("", 0)
6✔
212
    if env is not None:
6✔
213
        env = {**os.environ, **env}
6✔
214
    r = _run_shell(cmd, env=env, **kw)
6✔
215
    if rc := r.returncode:
6✔
216
        if _exit:
6✔
217
            sys.exit(rc)
6✔
218
        raise Exit(rc)
6✔
219
    return r
6✔
220

221

222
class DryRun:
6✔
223
    def __init__(self: Self, _exit: bool = False, dry: bool = False) -> None:
6✔
224
        self.dry = dry
6✔
225
        self._exit = _exit
6✔
226

227
    def gen(self: Self) -> str:
6✔
228
        raise NotImplementedError
6✔
229

230
    def run(self: Self) -> None:
6✔
231
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
6✔
232

233

234
class BumpUp(DryRun):
6✔
235
    class PartChoices(StrEnum):
6✔
236
        patch = "patch"
6✔
237
        minor = "minor"
6✔
238
        major = "major"
6✔
239

240
    def __init__(
6✔
241
        self: Self,
242
        commit: bool,
243
        part: str,
244
        filename: str | None = None,
245
        dry: bool = False,
246
    ) -> None:
247
        self.commit = commit
6✔
248
        self.part = part
6✔
249
        if filename is None:
6✔
250
            filename = self.parse_filename()
6✔
251
        self.filename = filename
6✔
252
        super().__init__(dry=dry)
6✔
253

254
    @staticmethod
6✔
255
    def get_last_commit_message(raises: bool = False) -> str:
6✔
256
        cmd = 'git show --pretty=format:"%s" -s HEAD'
6✔
257
        return capture_cmd_output(cmd, raises=raises)
6✔
258

259
    @classmethod
6✔
260
    def should_add_emoji(cls) -> bool:
6✔
261
        """
262
        If last commit message is startswith emoji,
263
        add a ⬆️ flag at the prefix of bump up commit message.
264
        """
265
        try:
6✔
266
            first_char = cls.get_last_commit_message(raises=True)[0]
6✔
267
        except (IndexError, ShellCommandError):
6✔
268
            return False
6✔
269
        else:
270
            return emoji.is_emoji(first_char)
6✔
271

272
    @staticmethod
6✔
273
    def parse_filename(
6✔
274
        toml_text: str | None = None,
275
        work_dir: Path | None = None,
276
        package_name: str | None = None,
277
    ) -> str:
278
        if toml_text is None:
6✔
279
            toml_text = Project.load_toml_text()
6✔
280
        context = tomllib.loads(toml_text)
6✔
281
        by_version_plugin = False
6✔
282
        try:
6✔
283
            ver = context["project"]["version"]
6✔
284
        except KeyError:
6✔
285
            pass
6✔
286
        else:
287
            if isinstance(ver, str):
6✔
288
                if ver in ("0", "0.0.0"):
6✔
289
                    by_version_plugin = True
6✔
290
                elif re.match(r"\d+\.\d+\.\d+", ver):
6✔
291
                    return TOML_FILE
6✔
292
        if not by_version_plugin:
6✔
293
            try:
6✔
294
                version_value = context["tool"]["poetry"]["version"]
6✔
295
            except KeyError:
6✔
296
                if not Project.manage_by_poetry():
6✔
297
                    if work_dir is None:
6✔
298
                        work_dir = Project.get_work_dir()
6✔
299
                    for tool in ("pdm", "hatch"):
6✔
300
                        with contextlib.suppress(KeyError):
6✔
301
                            version_path = context["tool"][tool]["version"]["path"]
6✔
302
                            if (
6✔
303
                                Path(version_path).exists()
304
                                or work_dir.joinpath(version_path).exists()
305
                            ):
306
                                return version_path
6✔
307
                    # version = { source = "file", path = "fast_dev_cli/__init__.py" }
308
                    v_key = "version = "
6✔
309
                    p_key = 'path = "'
6✔
310
                    for line in toml_text.splitlines():
6✔
UNCOV
311
                        if not line.startswith(v_key):
×
UNCOV
312
                            continue
×
UNCOV
313
                        if p_key in (value := line.split(v_key, 1)[-1].split("#")[0]):
×
UNCOV
314
                            filename = value.split(p_key, 1)[-1].split('"')[0]
×
UNCOV
315
                            if work_dir.joinpath(filename).exists():
×
UNCOV
316
                                return filename
×
317
            else:
318
                by_version_plugin = version_value in ("0", "0.0.0", "init")
6✔
319
        if by_version_plugin:
6✔
320
            try:
6✔
321
                package_item = context["tool"]["poetry"]["packages"]
6✔
322
            except KeyError:
6✔
323
                try:
6✔
324
                    project_name = context["project"]["name"]
6✔
325
                except KeyError:
6✔
326
                    packages = []
6✔
327
                else:
328
                    packages = [(poetry_module_name(project_name), "")]
6✔
329
            else:
330
                packages = [
6✔
331
                    (j, i.get("from", ""))
332
                    for i in package_item
333
                    if (j := i.get("include"))
334
                ]
335
            # In case of managed by `poetry-plugin-version`
336
            cwd = Path.cwd()
6✔
337
            pattern = re.compile(r"__version__\s*=\s*['\"]")
6✔
338
            ds: list[Path] = []
6✔
339
            if package_name is not None:
6✔
UNCOV
340
                packages.insert(0, (package_name, ""))
×
341
            for package_name, source_dir in packages:
6✔
342
                ds.append(cwd / package_name)
6✔
343
                ds.append(cwd / "src" / package_name)
6✔
344
                if source_dir and source_dir != "src":
6✔
345
                    ds.append(cwd / source_dir / package_name)
6✔
346
            module_name = poetry_module_name(cwd.name)
6✔
347
            ds.extend([cwd / module_name, cwd / "src" / module_name, cwd])
6✔
348
            for d in ds:
6✔
349
                init_file = d / "__init__.py"
6✔
350
                if (
6✔
351
                    init_file.exists() and pattern.search(init_file.read_text("utf8"))
352
                ) or (
353
                    (init_file := init_file.with_name("__version__.py")).exists()
354
                    and pattern.search(init_file.read_text("utf8"))
355
                ):
356
                    break
6✔
357
            else:
358
                raise ParseError("Version file not found! Where are you now?")
6✔
359
            return os.path.relpath(init_file, cwd)
6✔
360

361
        return TOML_FILE
6✔
362

363
    def get_part(self, s: str) -> str:
6✔
364
        choices: dict[str, str] = {}
6✔
365
        for i, p in enumerate(self.PartChoices, 1):
6✔
366
            v = str(p)
6✔
367
            choices.update({str(i): v, v: v})
6✔
368
        try:
6✔
369
            return choices[s]
6✔
370
        except KeyError as e:
6✔
371
            echo(f"Invalid part: {s!r}")
6✔
372
            raise Exit(1) from e
6✔
373

374
    def gen(self: Self) -> str:
6✔
375
        _version = get_current_version()
6✔
376
        filename = self.filename
6✔
377
        echo(f"Current version(@{filename}): {_version}")
6✔
378
        if self.part:
6✔
379
            part = self.get_part(self.part)
6✔
380
        else:
381
            part = "patch"
6✔
382
            if a := input("Which one?").strip():
6✔
383
                part = self.get_part(a)
6✔
384
        self.part = part
6✔
385
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
6✔
386
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
6✔
387
        if self.commit:
6✔
388
            if part != "patch":
6✔
389
                cmd += " --tag"
6✔
390
            cmd += " --commit"
6✔
391
            if self.should_add_emoji():
6✔
392
                cmd += " --message-emoji=1"
6✔
393
            if not load_bool("DONT_GIT_PUSH"):
6✔
394
                cmd += " && git push && git push --tags && git log -1"
6✔
395
        else:
396
            cmd += " --allow-dirty"
6✔
397
        return cmd
6✔
398

399
    def run(self: Self) -> None:
6✔
400
        super().run()
6✔
401
        if not self.commit and not self.dry:
6✔
402
            new_version = get_current_version(True)
6✔
403
            echo(new_version)
6✔
404
            if self.part != "patch":
6✔
405
                echo("You may want to pin tag by `fast tag`")
6✔
406

407

408
@cli.command()
6✔
409
def version() -> None:
6✔
410
    """Show the version of this tool"""
411
    echo(f"Fast Dev Cli version: {__version__}")
6✔
412

413

414
@cli.command(name="bump")
6✔
415
def bump_version(
6✔
416
    part: BumpUp.PartChoices,
417
    commit: bool = Option(
418
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
419
    ),
420
    dry: bool = DryOption,
421
) -> None:
422
    """Bump up version string in pyproject.toml"""
423
    return BumpUp(_ensure_bool(commit), getattr(part, "value", part), dry=dry).run()
6✔
424

425

426
def bump() -> None:
6✔
427
    part, commit = "", False
6✔
428
    if args := sys.argv[2:]:
6✔
429
        if "-c" in args or "--commit" in args:
6✔
430
            commit = True
6✔
431
        for a in args:
6✔
432
            if not a.startswith("-"):
6✔
433
                part = a
6✔
434
                break
6✔
435
    return BumpUp(commit, part, dry="--dry" in args).run()
6✔
436

437

438
class EnvError(Exception):
6✔
439
    """Raise when expected to be managed by poetry, but toml file not found."""
440

441

442
class Project:
6✔
443
    path_depth = 5
6✔
444

445
    @staticmethod
6✔
446
    def is_poetry_v2(text: str) -> bool:
6✔
447
        return 'build-backend = "poetry' in text
6✔
448

449
    @staticmethod
6✔
450
    def work_dir(
6✔
451
        name: str, parent: Path, depth: int, be_file: bool = False
452
    ) -> Path | None:
453
        for _ in range(depth):
6✔
454
            if (f := parent.joinpath(name)).exists():
6✔
455
                if be_file:
6✔
456
                    return f
6✔
457
                return parent
6✔
458
            parent = parent.parent
6✔
459
        return None
6✔
460

461
    @classmethod
6✔
462
    def get_work_dir(
6✔
463
        cls: type[Self],
464
        name: str = TOML_FILE,
465
        cwd: Path | None = None,
466
        allow_cwd: bool = False,
467
        be_file: bool = False,
468
    ) -> Path:
469
        cwd = cwd or Path.cwd()
6✔
470
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
6✔
471
            return d
6✔
472
        if allow_cwd:
6✔
473
            return cls.get_root_dir(cwd)
6✔
474
        raise EnvError(f"{name} not found! Make sure this is a poetry project.")
6✔
475

476
    @classmethod
6✔
477
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
6✔
478
        toml_file = cls.get_work_dir(name, be_file=True)
6✔
479
        return toml_file.read_text("utf8")
6✔
480

481
    @classmethod
6✔
482
    def manage_by_poetry(cls: type[Self]) -> bool:
6✔
483
        return cls.get_manage_tool() == "poetry"
6✔
484

485
    @classmethod
6✔
486
    def get_manage_tool(cls: type[Self]) -> ToolName | None:
6✔
487
        try:
6✔
488
            text = cls.load_toml_text()
6✔
489
        except EnvError:
6✔
490
            pass
6✔
491
        else:
492
            for name in get_args(ToolName):
6✔
493
                if f"[tool.{name}]" in text:
6✔
494
                    return cast(ToolName, name)
6✔
495
            # Poetry 2.0 default to not include the '[tool.poetry]' section
496
            if cls.is_poetry_v2(text):
6✔
497
                return "poetry"
6✔
498
        return None
6✔
499

500
    @staticmethod
6✔
501
    def python_exec_dir() -> Path:
6✔
502
        return Path(sys.executable).parent
6✔
503

504
    @classmethod
6✔
505
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
6✔
506
        root = cwd or Path.cwd()
6✔
507
        venv_parent = cls.python_exec_dir().parent.parent
6✔
508
        if root.is_relative_to(venv_parent):
6✔
509
            root = venv_parent
6✔
510
        return root
6✔
511

512

513
class ParseError(Exception):
6✔
514
    """Raise this if parse dependence line error"""
515

516
    pass
6✔
517

518

519
class UpgradeDependencies(Project, DryRun):
6✔
520
    def __init__(
6✔
521
        self: Self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
522
    ) -> None:
523
        super().__init__(_exit, dry)
6✔
524
        self._tool = tool
6✔
525

526
    class DevFlag(StrEnum):
6✔
527
        new = "[tool.poetry.group.dev.dependencies]"
6✔
528
        old = "[tool.poetry.dev-dependencies]"
6✔
529

530
    @staticmethod
6✔
531
    def parse_value(version_info: str, key: str) -> str:
6✔
532
        """Pick out the value for key in version info.
533

534
        Example::
535
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
536
            >>> UpgradeDependencies.parse_value(s, 'extras')
537
            'all'
538
            >>> UpgradeDependencies.parse_value(s, 'optional')
539
            'true'
540
            >>> UpgradeDependencies.parse_value(s, 'version')
541
            '^0.9.0'
542
        """
543
        sep = key + " = "
6✔
544
        rest = version_info.split(sep, 1)[-1].strip(" =")
6✔
545
        if rest.startswith("["):
6✔
546
            rest = rest[1:].split("]")[0]
6✔
547
        elif rest.startswith('"'):
6✔
548
            rest = rest[1:].split('"')[0]
6✔
549
        else:
550
            rest = rest.split(",")[0].split("}")[0]
6✔
551
        return rest.strip().replace('"', "")
6✔
552

553
    @staticmethod
6✔
554
    def no_need_upgrade(version_info: str, line: str) -> bool:
6✔
555
        if (v := version_info.replace(" ", "")).startswith("{url="):
6✔
556
            echo(f"No need to upgrade for: {line}")
6✔
557
            return True
6✔
558
        if (f := "version=") in v:
6✔
559
            v = v.split(f)[1].strip('"').split('"')[0]
6✔
560
        if v == "*":
6✔
561
            echo(f"Skip wildcard line: {line}")
6✔
562
            return True
6✔
563
        elif v == "[":
6✔
564
            echo(f"Skip complex dependence: {line}")
6✔
565
            return True
6✔
566
        elif v.startswith(">") or v.startswith("<") or v[0].isdigit():
6✔
567
            echo(f"Ignore bigger/smaller/equal: {line}")
6✔
568
            return True
6✔
569
        return False
6✔
570

571
    @classmethod
6✔
572
    def build_args(
6✔
573
        cls: type[Self], package_lines: list[str]
574
    ) -> tuple[list[str], dict[str, list[str]]]:
575
        args: list[str] = []  # ['typer[all]', 'fastapi']
6✔
576
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
6✔
577
        for no, line in enumerate(package_lines, 1):
6✔
578
            if (
6✔
579
                not (m := line.strip())
580
                or m.startswith("#")
581
                or m == "]"
582
                or (m.startswith("{") and m.strip(",").endswith("}"))
583
            ):
584
                continue
6✔
585
            try:
6✔
586
                package, version_info = m.split("=", 1)
6✔
587
            except ValueError as e:
6✔
588
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
6✔
589
            if (package := package.strip()).lower() == "python":
6✔
590
                continue
6✔
591
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
6✔
592
                continue
6✔
593
            if (extras_tip := "extras") in version_info:
6✔
594
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
6✔
595
            item = f'"{package}@latest"'
6✔
596
            key = None
6✔
597
            if (pf := "platform") in version_info:
6✔
598
                platform = cls.parse_value(version_info, pf)
6✔
599
                key = f"--{pf}={platform}"
6✔
600
            if (sc := "source") in version_info:
6✔
601
                source = cls.parse_value(version_info, sc)
6✔
602
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
6✔
603
            if "optional = true" in version_info:
6✔
604
                key = ("" if key is None else (key + " ")) + "--optional"
6✔
605
            if key is not None:
6✔
606
                specials[key] = specials.get(key, []) + [item]
6✔
607
            else:
608
                args.append(item)
6✔
609
        return args, specials
6✔
610

611
    @classmethod
6✔
612
    def should_with_dev(cls: type[Self]) -> bool:
6✔
613
        text = cls.load_toml_text()
6✔
614
        return cls.DevFlag.new in text or cls.DevFlag.old in text
6✔
615

616
    @staticmethod
6✔
617
    def parse_item(toml_str: str) -> list[str]:
6✔
618
        lines: list[str] = []
6✔
619
        for line in toml_str.splitlines():
6✔
620
            if (line := line.strip()).startswith("["):
6✔
621
                if lines:
6✔
622
                    break
6✔
623
            elif line:
6✔
624
                lines.append(line)
6✔
625
        return lines
6✔
626

627
    @classmethod
6✔
628
    def get_args(
6✔
629
        cls: type[Self], toml_text: str | None = None
630
    ) -> tuple[list[str], list[str], list[list[str]], str]:
631
        if toml_text is None:
6✔
632
            toml_text = cls.load_toml_text()
6✔
633
        main_title = "[tool.poetry.dependencies]"
6✔
634
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
6✔
635
            toml_text
636
        ):
637
            raise EnvError(
6✔
638
                f"{main_title} not found! Make sure this is a poetry project."
639
            )
640
        text = toml_text.split(main_title)[-1]
6✔
641
        dev_flag = "--group dev"
6✔
642
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
6✔
643
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
6✔
644
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
6✔
645
            dev_flag = "--dev"
6✔
646
        others: list[list[str]] = []
6✔
647
        try:
6✔
648
            main_toml, dev_toml = text.split(dev_title)
6✔
649
        except ValueError:
6✔
650
            dev_toml = ""
6✔
651
            main_toml = text
6✔
652
        mains = [] if no_main_deps else cls.parse_item(main_toml)
6✔
653
        devs = cls.parse_item(dev_toml)
6✔
654
        prod_packs, specials = cls.build_args(mains)
6✔
655
        if specials:
6✔
656
            others.extend([[k] + v for k, v in specials.items()])
6✔
657
        dev_packs, specials = cls.build_args(devs)
6✔
658
        if specials:
6✔
659
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
6✔
660
        return prod_packs, dev_packs, others, dev_flag
6✔
661

662
    @classmethod
6✔
663
    def gen_cmd(cls: type[Self]) -> str:
6✔
664
        main_args, dev_args, others, dev_flags = cls.get_args()
6✔
665
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
6✔
666

667
    @staticmethod
6✔
668
    def to_cmd(
6✔
669
        main_args: list[str],
670
        dev_args: list[str],
671
        others: list[list[str]],
672
        dev_flags: str,
673
    ) -> str:
674
        command = "poetry add "
6✔
675
        _upgrade = ""
6✔
676
        if main_args:
6✔
677
            _upgrade = command + " ".join(main_args)
6✔
678
        if dev_args:
6✔
679
            if _upgrade:
6✔
680
                _upgrade += " && "
6✔
681
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
6✔
682
        for single in others:
6✔
683
            _upgrade += f" && poetry add {' '.join(single)}"
6✔
684
        return _upgrade
6✔
685

686
    def gen(self: Self) -> str:
6✔
687
        if self._tool == "uv":
6✔
688
            return "uv lock --upgrade --verbose && uv sync --frozen"
6✔
689
        elif self._tool == "pdm":
6✔
690
            return "pdm update --verbose && pdm install"
6✔
691
        return self.gen_cmd() + " && poetry lock && poetry update"
6✔
692

693

694
@cli.command()
6✔
695
def upgrade(
6✔
696
    tool: str = ToolOption,
697
    dry: bool = DryOption,
698
) -> None:
699
    """Upgrade dependencies in pyproject.toml to latest versions"""
700
    if not (tool := _ensure_str(tool)) or tool == ToolOption.default:
6✔
701
        tool = Project.get_manage_tool() or "uv"
6✔
702
    if tool in get_args(ToolName):
6✔
703
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
6✔
704
    else:
705
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
6✔
706
        raise typer.Exit(1)
6✔
707

708

709
class GitTag(DryRun):
6✔
710
    def __init__(self: Self, message: str, dry: bool) -> None:
6✔
711
        self.message = message
6✔
712
        super().__init__(dry=dry)
6✔
713

714
    @staticmethod
6✔
715
    def has_v_prefix() -> bool:
6✔
716
        return "v" in capture_cmd_output("git tag")
6✔
717

718
    def should_push(self: Self) -> bool:
6✔
719
        return "git push" in self.git_status
6✔
720

721
    def gen(self: Self) -> str:
6✔
722
        _version = get_current_version(verbose=False)
6✔
723
        if self.has_v_prefix():
6✔
724
            # Add `v` at prefix to compare with bumpversion tool
725
            _version = "v" + _version
6✔
726
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
6✔
727
        if self.should_push():
6✔
728
            cmd += " && git push"
6✔
729
        return cmd
6✔
730

731
    @cached_property
6✔
732
    def git_status(self: Self) -> str:
6✔
733
        return capture_cmd_output("git status")
6✔
734

735
    def mark_tag(self: Self) -> bool:
6✔
736
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
6✔
737
            "无文件要提交,干净的工作区" not in self.git_status
738
        ):
739
            run_and_echo("git status")
6✔
740
            echo("ERROR: Please run git commit to make sure working tree is clean!")
6✔
741
            return False
6✔
742
        return bool(super().run())
6✔
743

744
    def run(self: Self) -> None:
6✔
745
        if self.mark_tag() and not self.dry:
6✔
746
            echo("You may want to publish package:\n poetry publish --build")
6✔
747

748

749
@cli.command()
6✔
750
def tag(
6✔
751
    message: str = Option("", "-m", "--message"),
752
    dry: bool = DryOption,
753
) -> None:
754
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
755
    GitTag(message, dry=dry).run()
6✔
756

757

758
class LintCode(DryRun):
6✔
759
    def __init__(
6✔
760
        self: Self,
761
        args: list[str] | str | None,
762
        check_only: bool = False,
763
        _exit: bool = False,
764
        dry: bool = False,
765
        bandit: bool = False,
766
        skip_mypy: bool = False,
767
        dmypy: bool = False,
768
        tool: str = ToolOption.default,
769
    ) -> None:
770
        self.args = args
6✔
771
        self.check_only = check_only
6✔
772
        self._bandit = bandit
6✔
773
        self._skip_mypy = skip_mypy
6✔
774
        self._use_dmypy = dmypy
6✔
775
        self._tool = tool
6✔
776
        super().__init__(_exit, dry)
6✔
777

778
    @staticmethod
6✔
779
    def check_lint_tool_installed() -> bool:
6✔
780
        return check_call("ruff --version")
6✔
781

782
    @staticmethod
6✔
783
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
6✔
784
        return (
6✔
785
            paths == "."
786
            and any(t.startswith("mypy") for t in tools)
787
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
788
        )
789

790
    @staticmethod
6✔
791
    def get_package_name() -> str:
6✔
792
        root = Project.get_work_dir(allow_cwd=True)
6✔
793
        module_name = root.name.replace("-", "_").replace(" ", "_")
6✔
794
        package_maybe = (module_name, "src")
6✔
795
        for name in package_maybe:
6✔
796
            if root.joinpath(name).is_dir():
6✔
797
                return name
6✔
798
        return "."
6✔
799

800
    @classmethod
6✔
801
    def to_cmd(
6✔
802
        cls: type[Self],
803
        paths: str = ".",
804
        check_only: bool = False,
805
        bandit: bool = False,
806
        skip_mypy: bool = False,
807
        use_dmypy: bool = False,
808
        tool: str = ToolOption.default,
809
    ) -> str:
810
        if paths != "." and all(i.endswith(".html") for i in paths.split()):
6✔
811
            return f"prettier -w {paths}"
6✔
812
        cmd = ""
6✔
813
        tools = ["ruff format", "ruff check --extend-select=I,B,SIM --fix", "mypy"]
6✔
814
        if check_only:
6✔
815
            tools[0] += " --check"
6✔
816
        if check_only or load_bool("NO_FIX"):
6✔
817
            tools[1] = tools[1].replace(" --fix", "")
6✔
818
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
6✔
819
            # Sometimes mypy is too slow
820
            tools = tools[:-1]
6✔
821
        elif load_bool("IGNORE_MISSING_IMPORTS"):
6✔
822
            tools[-1] += " --ignore-missing-imports"
6✔
823
        lint_them = " && ".join(
6✔
824
            "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
825
        )
826
        prefix = ""
6✔
827
        should_run_by_tool = False
6✔
828
        if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(".local/bin"):
6✔
829
            if not cls.check_lint_tool_installed():
6✔
830
                should_run_by_tool = True
6✔
831
                if check_call('python -c "import fast_dev_cli"'):
6✔
832
                    command = 'python -m pip install -U "fast-dev-cli"'
6✔
833
                    tip = "You may need to run following command to install lint tools:"
6✔
834
                    secho(f"{tip}\n\n  {command}\n", fg="yellow")
6✔
835
        else:
836
            should_run_by_tool = True
6✔
837
        if should_run_by_tool and tool:
6✔
838
            if tool == ToolOption.default:
6✔
839
                tool = Project.get_manage_tool() or ""
6✔
840
            if tool:
6✔
841
                prefix = tool + " run "
6✔
842
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
6✔
843
            tools[-1] = "dmypy run"
6✔
844
        cmd += lint_them.format(prefix, paths, *tools)
6✔
845
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
6✔
846
            command = prefix + "bandit"
6✔
847
            if Path("pyproject.toml").exists():
6✔
848
                toml_text = Project.load_toml_text()
6✔
849
                if "[tool.bandit" in toml_text:
6✔
850
                    command += " -c pyproject.toml"
6✔
851
            if paths == "." and " -c " not in command:
6✔
852
                paths = cls.get_package_name()
6✔
853
            command += f" -r {paths}"
6✔
854
            cmd += " && " + command
6✔
855
        return cmd
6✔
856

857
    def gen(self: Self) -> str:
6✔
858
        if isinstance(args := self.args, str):
6✔
859
            args = args.split()
6✔
860
        paths = " ".join(map(str, args)) if args else "."
6✔
861
        return self.to_cmd(
6✔
862
            paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy
863
        )
864

865

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

869

870
def lint(
6✔
871
    files: list[str] | str | None = None,
872
    dry: bool = False,
873
    bandit: bool = False,
874
    skip_mypy: bool = False,
875
    dmypy: bool = False,
876
    tool: str = ToolOption.default,
877
) -> None:
878
    if files is None:
6✔
879
        files = parse_files(sys.argv[1:])
6✔
880
    if files and files[0] == "lint":
6✔
881
        files = files[1:]
6✔
882
    LintCode(
6✔
883
        files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy, tool=tool
884
    ).run()
885

886

887
def check(
6✔
888
    files: list[str] | str | None = None,
889
    dry: bool = False,
890
    bandit: bool = False,
891
    skip_mypy: bool = False,
892
    dmypy: bool = False,
893
    tool: str = ToolOption.default,
894
) -> None:
895
    LintCode(
6✔
896
        files,
897
        check_only=True,
898
        _exit=True,
899
        dry=dry,
900
        bandit=bandit,
901
        skip_mypy=skip_mypy,
902
        dmypy=dmypy,
903
        tool=tool,
904
    ).run()
905

906

907
@cli.command(name="lint")
6✔
908
def make_style(
6✔
909
    files: Optional[list[str]] = typer.Argument(default=None),  # noqa:B008
910
    check_only: bool = Option(False, "--check-only", "-c"),
911
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
912
    skip_mypy: bool = Option(False, "--skip-mypy"),
913
    use_dmypy: bool = Option(
914
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
915
    ),
916
    tool: str = ToolOption,
917
    dry: bool = DryOption,
918
) -> None:
919
    """Run: ruff check/format to reformat code and then mypy to check"""
920
    if getattr(files, "default", files) is None:
6✔
921
        files = ["."]
6✔
922
    elif isinstance(files, str):
6✔
923
        files = [files]
6✔
924
    skip = _ensure_bool(skip_mypy)
6✔
925
    dmypy = _ensure_bool(use_dmypy)
6✔
926
    bandit = _ensure_bool(bandit)
6✔
927
    tool = _ensure_str(tool)
6✔
928
    if _ensure_bool(check_only):
6✔
929
        check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
6✔
930
    else:
931
        lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
6✔
932

933

934
@cli.command(name="check")
6✔
935
def only_check(
6✔
936
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
937
    skip_mypy: bool = Option(False, "--skip-mypy"),
938
    dry: bool = DryOption,
939
) -> None:
940
    """Check code style without reformat"""
941
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
6✔
942

943

944
class Sync(DryRun):
6✔
945
    def __init__(
6✔
946
        self: Self, filename: str, extras: str, save: bool, dry: bool = False
947
    ) -> None:
948
        self.filename = filename
6✔
949
        self.extras = extras
6✔
950
        self._save = save
6✔
951
        super().__init__(dry=dry)
6✔
952

953
    def gen(self) -> str:
6✔
954
        extras, save = self.extras, self._save
6✔
955
        should_remove = not Path.cwd().joinpath(self.filename).exists()
6✔
956
        if not (tool := Project.get_manage_tool()):
6✔
957
            if should_remove or not is_venv():
6✔
958
                raise EnvError("There project is not managed by uv/pdm/poetry!")
6✔
959
            return f"python -m pip install -r {self.filename}"
6✔
960
        prefix = "" if is_venv() else f"{tool} run "
6✔
961
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
6✔
962
        export_cmd = "uv export --no-hashes --all-extras --frozen"
6✔
963
        if tool in ("poetry", "pdm"):
6✔
964
            export_cmd = f"{tool} export --without-hashes --with=dev"
6✔
965
            if tool == "poetry":
6✔
966
                ensure_pip = ""
6✔
967
                if not UpgradeDependencies.should_with_dev():
6✔
968
                    export_cmd = export_cmd.replace(" --with=dev", "")
6✔
969
                if extras and isinstance(extras, (str, list)):
6✔
970
                    export_cmd += f" --{extras=}".replace("'", '"')
6✔
971
            elif check_call(prefix + "python -m pip --version"):
6✔
972
                ensure_pip = ""
6✔
973
        elif check_call(prefix + "python -m pip --version"):
6✔
974
            ensure_pip = ""
6✔
975
        install_cmd = (
6✔
976
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
977
        )
978
        if should_remove and not save:
6✔
979
            install_cmd += " && rm -f {0}"
6✔
980
        return install_cmd.format(self.filename, prefix, export_cmd)
6✔
981

982

983
@cli.command()
6✔
984
def sync(
6✔
985
    filename: str = "dev_requirements.txt",
986
    extras: str = Option("", "--extras", "-E"),
987
    save: bool = Option(
988
        False, "--save", "-s", help="Whether save the requirement file"
989
    ),
990
    dry: bool = DryOption,
991
) -> None:
992
    """Export dependencies by poetry to a txt file then install by pip."""
993
    Sync(filename, extras, save, dry=dry).run()
6✔
994

995

996
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
6✔
997
    for name in ("test.sh", "test.py"):
6✔
998
        if (file := path / name).exists():
6✔
999
            return file
6✔
1000
    return None
6✔
1001

1002

1003
def test(dry: bool, ignore_script: bool = False) -> None:
6✔
1004
    cwd = Path.cwd()
6✔
1005
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
6✔
1006
    script_dir = root / "scripts"
6✔
1007
    if not _ensure_bool(ignore_script) and (
6✔
1008
        test_script := _should_run_test_script(script_dir)
1009
    ):
1010
        cmd = f"{os.path.relpath(test_script, root)}"
6✔
1011
        if cwd != root:
6✔
1012
            cmd = f"cd {root} && " + cmd
6✔
1013
    else:
1014
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
6✔
1015
        if not is_venv() or not check_call("coverage --version"):
6✔
1016
            sep = " && "
6✔
1017
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
6✔
1018
            cmd = sep.join(prefix + i for i in cmd.split(sep))
6✔
1019
    exit_if_run_failed(cmd, dry=dry)
6✔
1020

1021

1022
@cli.command(name="test")
6✔
1023
def coverage_test(
6✔
1024
    dry: bool = DryOption,
1025
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1026
) -> None:
1027
    """Run unittest by pytest and report coverage"""
1028
    return test(dry, ignore_script)
6✔
1029

1030

1031
class Publish:
6✔
1032
    class CommandEnum(StrEnum):
6✔
1033
        poetry = "poetry publish --build"
6✔
1034
        pdm = "pdm publish"
6✔
1035
        uv = "uv build && uv publish"
6✔
1036
        twine = "python -m build && twine upload"
6✔
1037

1038
    @classmethod
6✔
1039
    def gen(cls) -> str:
6✔
1040
        if tool := Project.get_manage_tool():
6✔
1041
            return cls.CommandEnum[tool]
6✔
1042
        return cls.CommandEnum.twine
6✔
1043

1044

1045
@cli.command()
6✔
1046
def upload(
6✔
1047
    dry: bool = DryOption,
1048
) -> None:
1049
    """Shortcut for package publish"""
1050
    cmd = Publish.gen()
6✔
1051
    exit_if_run_failed(cmd, dry=dry)
6✔
1052

1053

1054
def dev(
6✔
1055
    port: int | None | OptionInfo,
1056
    host: str | None | OptionInfo,
1057
    file: str | None | ArgumentInfo = None,
1058
    dry: bool = False,
1059
) -> None:
1060
    cmd = "fastapi dev"
6✔
1061
    no_port_yet = True
6✔
1062
    if file is not None:
6✔
1063
        try:
6✔
1064
            port = int(str(file))
6✔
1065
        except ValueError:
6✔
1066
            cmd += f" {file}"
6✔
1067
        else:
1068
            if port != 8000:
6✔
1069
                cmd += f" --port={port}"
6✔
1070
                no_port_yet = False
6✔
1071
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
6✔
1072
        cmd += f" --port={port}"
6✔
1073
    if (host := getattr(host, "default", host)) and host not in (
6✔
1074
        "localhost",
1075
        "127.0.0.1",
1076
    ):
1077
        cmd += f" --host={host}"
6✔
1078
    exit_if_run_failed(cmd, dry=dry)
6✔
1079

1080

1081
@cli.command(name="dev")
6✔
1082
def runserver(
6✔
1083
    file_or_port: Optional[str] = typer.Argument(default=None),
1084
    port: Optional[int] = Option(None, "-p", "--port"),
1085
    host: Optional[str] = Option(None, "-h", "--host"),
1086
    dry: bool = DryOption,
1087
) -> None:
1088
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1089
    if getattr(file_or_port, "default", file_or_port):
6✔
1090
        dev(port, host, file=file_or_port, dry=dry)
6✔
1091
    else:
1092
        dev(port, host, dry=dry)
6✔
1093

1094

1095
def main() -> None:
6✔
1096
    cli()
6✔
1097

1098

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