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

waketzheng / fast-dev-cli / 14467214320

15 Apr 2025 10:17AM UTC coverage: 98.476% (+0.03%) from 98.45%
14467214320

push

github

waketzheng
tests: improve coverage

646 of 656 relevant lines covered (98.48%)

4.92 hits per line

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

98.47
/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

45

46
class ShellCommandError(Exception): ...
5✔
47

48

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

53
    return canonicalize_name(name).replace("-", "_").replace(" ", "_")
5✔
54

55

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

66

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

73

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

79

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

88

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

93

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

102

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

106

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

137

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

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

160

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

166

167
def exit_if_run_failed(
5✔
168
    cmd: str, env=None, _exit=False, dry=False, **kw
169
) -> subprocess.CompletedProcess:
170
    run_and_echo(cmd, dry=True)
5✔
171
    if _ensure_bool(dry):
5✔
172
        return subprocess.CompletedProcess("", 0)
5✔
173
    if env is not None:
5✔
174
        env = {**os.environ, **env}
5✔
175
    r = _run_shell(cmd, env=env, **kw)
5✔
176
    if rc := r.returncode:
5✔
177
        if _exit:
5✔
178
            sys.exit(rc)
5✔
179
        raise Exit(rc)
5✔
180
    return r
5✔
181

182

183
class DryRun:
5✔
184
    def __init__(self: Self, _exit=False, dry=False) -> None:
5✔
185
        self.dry = dry
5✔
186
        self._exit = _exit
5✔
187

188
    def gen(self: Self) -> str:
5✔
189
        raise NotImplementedError
5✔
190

191
    def run(self: Self) -> None:
5✔
192
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
5✔
193

194

195
class BumpUp(DryRun):
5✔
196
    class PartChoices(StrEnum):
5✔
197
        patch = "patch"
5✔
198
        minor = "minor"
5✔
199
        major = "major"
5✔
200

201
    def __init__(
5✔
202
        self: Self, commit: bool, part: str, filename: str | None = None, dry=False
203
    ) -> None:
204
        self.commit = commit
5✔
205
        self.part = part
5✔
206
        if filename is None:
5✔
207
            filename = self.parse_filename()
5✔
208
        self.filename = filename
5✔
209
        super().__init__(dry=dry)
5✔
210

211
    @staticmethod
5✔
212
    def get_last_commit_message() -> str:
5✔
213
        cmd = 'git show --pretty=format:"%s" -s HEAD'
5✔
214
        return capture_cmd_output(cmd)
5✔
215

216
    @classmethod
5✔
217
    def should_add_emoji(cls) -> bool:
5✔
218
        """
219
        If last commit message is startswith emoji,
220
        add a ⬆️ flag at the prefix of bump up commit message.
221
        """
222
        if out := cls.get_last_commit_message():
5✔
223
            return emoji.is_emoji(out[0])
5✔
224
        return False
×
225

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

293
        return TOML_FILE
5✔
294

295
    def get_part(self, s: str) -> str:
5✔
296
        choices: dict[str, str] = {}
5✔
297
        for i, p in enumerate(self.PartChoices, 1):
5✔
298
            v = str(p)
5✔
299
            choices.update({str(i): v, v: v})
5✔
300
        try:
5✔
301
            return choices[s]
5✔
302
        except KeyError as e:
5✔
303
            echo(f"Invalid part: {s!r}")
5✔
304
            raise Exit(1) from e
5✔
305

306
    def gen(self: Self) -> str:
5✔
307
        _version = get_current_version()
5✔
308
        filename = self.filename
5✔
309
        echo(f"Current version(@{filename}): {_version}")
5✔
310
        if self.part:
5✔
311
            part = self.get_part(self.part)
5✔
312
        else:
313
            part = "patch"
5✔
314
            if a := input("Which one?").strip():
5✔
315
                part = self.get_part(a)
5✔
316
        self.part = part
5✔
317
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5✔
318
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
5✔
319
        if self.commit:
5✔
320
            if part != "patch":
5✔
321
                cmd += " --tag"
5✔
322
            cmd += " --commit"
5✔
323
            if self.should_add_emoji():
5✔
324
                cmd += " --message-emoji=1"
5✔
325
            if not load_bool("DONT_GIT_PUSH"):
5✔
326
                cmd += " && git push && git push --tags && git log -1"
5✔
327
        else:
328
            cmd += " --allow-dirty"
5✔
329
        return cmd
5✔
330

331
    def run(self: Self) -> None:
5✔
332
        super().run()
5✔
333
        if not self.commit and not self.dry:
5✔
334
            new_version = get_current_version(True)
5✔
335
            echo(new_version)
5✔
336
            if self.part != "patch":
5✔
337
                echo("You may want to pin tag by `fast tag`")
5✔
338

339

340
@cli.command()
5✔
341
def version() -> None:
5✔
342
    """Show the version of this tool"""
343
    echo(f"Fast Dev Cli version: {__version__}")
5✔
344

345

346
@cli.command(name="bump")
5✔
347
def bump_version(
5✔
348
    part: BumpUp.PartChoices,
349
    commit: bool = Option(
350
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
351
    ),
352
    dry: bool = DryOption,
353
) -> None:
354
    """Bump up version string in pyproject.toml"""
355
    return BumpUp(_ensure_bool(commit), getattr(part, "value", part), dry=dry).run()
5✔
356

357

358
def bump() -> None:
5✔
359
    part, commit = "", False
5✔
360
    if args := sys.argv[2:]:
5✔
361
        if "-c" in args or "--commit" in args:
5✔
362
            commit = True
5✔
363
        for a in args:
5✔
364
            if not a.startswith("-"):
5✔
365
                part = a
5✔
366
                break
5✔
367
    return BumpUp(commit, part, dry="--dry" in args).run()
5✔
368

369

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

373

374
class Project:
5✔
375
    path_depth = 5
5✔
376

377
    @staticmethod
5✔
378
    def is_poetry_v2(text: str) -> bool:
5✔
379
        return 'build-backend = "poetry' in text
5✔
380

381
    @staticmethod
5✔
382
    def work_dir(name: str, parent: Path, depth: int, be_file=False) -> Path | None:
5✔
383
        for _ in range(depth):
5✔
384
            if (f := parent.joinpath(name)).exists():
5✔
385
                if be_file:
5✔
386
                    return f
5✔
387
                return parent
5✔
388
            parent = parent.parent
5✔
389
        return None
5✔
390

391
    @classmethod
5✔
392
    def get_work_dir(
5✔
393
        cls: type[Self],
394
        name=TOML_FILE,
395
        cwd: Path | None = None,
396
        allow_cwd=False,
397
        be_file=False,
398
    ) -> Path:
399
        cwd = cwd or Path.cwd()
5✔
400
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5✔
401
            return d
5✔
402
        if allow_cwd:
5✔
403
            return cls.get_root_dir(cwd)
5✔
404
        raise EnvError(f"{name} not found! Make sure this is a poetry project.")
5✔
405

406
    @classmethod
5✔
407
    def load_toml_text(cls: type[Self], name=TOML_FILE) -> str:
5✔
408
        toml_file = cls.get_work_dir(name, be_file=True)
5✔
409
        return toml_file.read_text("utf8")
5✔
410

411
    @classmethod
5✔
412
    def manage_by_poetry(cls: type[Self]) -> bool:
5✔
413
        return cls.get_manage_tool() == "poetry"
5✔
414

415
    @classmethod
5✔
416
    def get_manage_tool(cls: type[Self]) -> ToolName | None:
5✔
417
        try:
5✔
418
            text = cls.load_toml_text()
5✔
419
        except EnvError:
5✔
420
            pass
5✔
421
        else:
422
            for name in get_args(ToolName):
5✔
423
                if f"[tool.{name}]" in text:
5✔
424
                    return name
5✔
425
            # Poetry 2.0 default to not include the '[tool.poetry]' section
426
            if cls.is_poetry_v2(text):
5✔
427
                return "poetry"
5✔
428
        return None
5✔
429

430
    @staticmethod
5✔
431
    def python_exec_dir() -> Path:
5✔
432
        return Path(sys.executable).parent
5✔
433

434
    @classmethod
5✔
435
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5✔
436
        root = cwd or Path.cwd()
5✔
437
        venv_parent = cls.python_exec_dir().parent.parent
5✔
438
        if root.is_relative_to(venv_parent):
5✔
439
            root = venv_parent
5✔
440
        return root
5✔
441

442

443
class ParseError(Exception):
5✔
444
    """Raise this if parse dependence line error"""
445

446
    pass
5✔
447

448

449
class UpgradeDependencies(Project, DryRun):
5✔
450
    class DevFlag(StrEnum):
5✔
451
        new = "[tool.poetry.group.dev.dependencies]"
5✔
452
        old = "[tool.poetry.dev-dependencies]"
5✔
453

454
    @staticmethod
5✔
455
    def parse_value(version_info: str, key: str) -> str:
5✔
456
        """Pick out the value for key in version info.
457

458
        Example::
459
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
460
            >>> UpgradeDependencies.parse_value(s, 'extras')
461
            'all'
462
            >>> UpgradeDependencies.parse_value(s, 'optional')
463
            'true'
464
            >>> UpgradeDependencies.parse_value(s, 'version')
465
            '^0.9.0'
466
        """
467
        sep = key + " = "
5✔
468
        rest = version_info.split(sep, 1)[-1].strip(" =")
5✔
469
        if rest.startswith("["):
5✔
470
            rest = rest[1:].split("]")[0]
5✔
471
        elif rest.startswith('"'):
5✔
472
            rest = rest[1:].split('"')[0]
5✔
473
        else:
474
            rest = rest.split(",")[0].split("}")[0]
5✔
475
        return rest.strip().replace('"', "")
5✔
476

477
    @staticmethod
5✔
478
    def no_need_upgrade(version_info: str, line: str) -> bool:
5✔
479
        if (v := version_info.replace(" ", "")).startswith("{url="):
5✔
480
            echo(f"No need to upgrade for: {line}")
5✔
481
            return True
5✔
482
        if (f := "version=") in v:
5✔
483
            v = v.split(f)[1].strip('"').split('"')[0]
5✔
484
        if v == "*":
5✔
485
            echo(f"Skip wildcard line: {line}")
5✔
486
            return True
5✔
487
        elif v == "[":
5✔
488
            echo(f"Skip complex dependence: {line}")
5✔
489
            return True
5✔
490
        elif v.startswith(">") or v.startswith("<") or v[0].isdigit():
5✔
491
            echo(f"Ignore bigger/smaller/equal: {line}")
5✔
492
            return True
5✔
493
        return False
5✔
494

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

535
    @classmethod
5✔
536
    def should_with_dev(cls: type[Self]) -> bool:
5✔
537
        text = cls.load_toml_text()
5✔
538
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
539

540
    @staticmethod
5✔
541
    def parse_item(toml_str) -> list[str]:
5✔
542
        lines: list[str] = []
5✔
543
        for line in toml_str.splitlines():
5✔
544
            if (line := line.strip()).startswith("["):
5✔
545
                if lines:
5✔
546
                    break
5✔
547
            elif line:
5✔
548
                lines.append(line)
5✔
549
        return lines
5✔
550

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

586
    @classmethod
5✔
587
    def gen_cmd(cls: type[Self]) -> str:
5✔
588
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
589
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
590

591
    @staticmethod
5✔
592
    def to_cmd(
5✔
593
        main_args: list[str],
594
        dev_args: list[str],
595
        others: list[list[str]],
596
        dev_flags: str,
597
    ) -> str:
598
        command = "poetry add "
5✔
599
        _upgrade = ""
5✔
600
        if main_args:
5✔
601
            _upgrade = command + " ".join(main_args)
5✔
602
        if dev_args:
5✔
603
            if _upgrade:
5✔
604
                _upgrade += " && "
5✔
605
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5✔
606
        for single in others:
5✔
607
            _upgrade += f" && poetry add {' '.join(single)}"
5✔
608
        return _upgrade
5✔
609

610
    def gen(self: Self) -> str:
5✔
611
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
612

613

614
@cli.command()
5✔
615
def upgrade(
5✔
616
    dry: bool = DryOption,
617
) -> None:
618
    """Upgrade dependencies in pyproject.toml to latest versions"""
619
    if (tool := Project.get_manage_tool()) == "uv":
5✔
620
        exit_if_run_failed("uv lock --upgrade && uv sync", dry=dry)
×
621
    elif tool == "pdm":
5✔
622
        exit_if_run_failed("pdm update && pdm install", dry=dry)
×
623
    else:
624
        UpgradeDependencies(dry=dry).run()
5✔
625

626

627
class GitTag(DryRun):
5✔
628
    def __init__(self: Self, message: str, dry: bool) -> None:
5✔
629
        self.message = message
5✔
630
        super().__init__(dry=dry)
5✔
631

632
    @staticmethod
5✔
633
    def has_v_prefix() -> bool:
5✔
634
        return "v" in capture_cmd_output("git tag")
5✔
635

636
    def should_push(self: Self) -> bool:
5✔
637
        return "git push" in self.git_status
5✔
638

639
    def gen(self: Self) -> str:
5✔
640
        _version = get_current_version(verbose=False)
5✔
641
        if self.has_v_prefix():
5✔
642
            # Add `v` at prefix to compare with bumpversion tool
643
            _version = "v" + _version
5✔
644
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
5✔
645
        if self.should_push():
5✔
646
            cmd += " && git push"
5✔
647
        return cmd
5✔
648

649
    @cached_property
5✔
650
    def git_status(self: Self) -> str:
5✔
651
        return capture_cmd_output("git status")
5✔
652

653
    def mark_tag(self: Self) -> bool:
5✔
654
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5✔
655
            "无文件要提交,干净的工作区" not in self.git_status
656
        ):
657
            run_and_echo("git status")
5✔
658
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5✔
659
            return False
5✔
660
        return bool(super().run())
5✔
661

662
    def run(self: Self) -> None:
5✔
663
        if self.mark_tag() and not self.dry:
5✔
664
            echo("You may want to publish package:\n poetry publish --build")
5✔
665

666

667
@cli.command()
5✔
668
def tag(
5✔
669
    message: str = Option("", "-m", "--message"),
670
    dry: bool = DryOption,
671
) -> None:
672
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
673
    GitTag(message, dry=dry).run()
5✔
674

675

676
class LintCode(DryRun):
5✔
677
    def __init__(
5✔
678
        self: Self,
679
        args,
680
        check_only=False,
681
        _exit=False,
682
        dry=False,
683
        bandit=False,
684
        skip_mypy=False,
685
        dmypy=False,
686
    ) -> None:
687
        self.args = args
5✔
688
        self.check_only = check_only
5✔
689
        self._bandit = bandit
5✔
690
        self._skip_mypy = skip_mypy
5✔
691
        self._use_dmypy = dmypy
5✔
692
        super().__init__(_exit, dry)
5✔
693

694
    @staticmethod
5✔
695
    def check_lint_tool_installed() -> bool:
5✔
696
        return check_call("ruff --version")
5✔
697

698
    @staticmethod
5✔
699
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy=False) -> bool:
5✔
700
        return (
5✔
701
            paths == "."
702
            and any(t.startswith("mypy") for t in tools)
703
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
704
        )
705

706
    @staticmethod
5✔
707
    def get_package_name() -> str:
5✔
708
        root = Project.get_work_dir(allow_cwd=True)
5✔
709
        package_maybe = (root.name.replace("-", "_"), "src")
5✔
710
        for name in package_maybe:
5✔
711
            if root.joinpath(name).is_dir():
5✔
712
                return name
×
713
        return "."
5✔
714

715
    @classmethod
5✔
716
    def to_cmd(
5✔
717
        cls: type[Self],
718
        paths: str = ".",
719
        check_only: bool = False,
720
        bandit: bool = False,
721
        skip_mypy: bool = False,
722
        use_dmypy: bool = False,
723
    ) -> str:
724
        cmd = ""
5✔
725
        tools = ["ruff format", "ruff check --extend-select=I,B,SIM --fix", "mypy"]
5✔
726
        if check_only:
5✔
727
            tools[0] += " --check"
5✔
728
        if check_only or load_bool("NO_FIX"):
5✔
729
            tools[1] = tools[1].replace(" --fix", "")
5✔
730
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
5✔
731
            # Sometimes mypy is too slow
732
            tools = tools[:-1]
5✔
733
        elif load_bool("IGNORE_MISSING_IMPORTS"):
5✔
734
            tools[-1] += " --ignore-missing-imports"
5✔
735
        lint_them = " && ".join(
5✔
736
            "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
737
        )
738
        prefix = ""
5✔
739
        should_run_by_tool = False
5✔
740
        if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(".local/bin"):
5✔
741
            if not cls.check_lint_tool_installed():
5✔
742
                should_run_by_tool = True
5✔
743
                if check_call('python -c "import fast_dev_cli"'):
5✔
744
                    command = 'python -m pip install -U "fast-dev-cli"'
5✔
745
                    tip = "You may need to run following command to install lint tools:"
5✔
746
                    secho(f"{tip}\n\n  {command}\n", fg="yellow")
5✔
747
        else:
748
            should_run_by_tool = True
5✔
749
        if should_run_by_tool and (manage_tool := Project.get_manage_tool()):
5✔
750
            prefix = manage_tool + " run "
5✔
751
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
5✔
752
            tools[-1] = "dmypy run"
×
753
        cmd += lint_them.format(prefix, paths, *tools)
5✔
754
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
5✔
755
            command = prefix + "bandit"
5✔
756
            if Path("pyproject.toml").exists():
5✔
757
                toml_text = Project.load_toml_text()
5✔
758
                if "[tool.bandit" in toml_text:
5✔
759
                    command += " -c pyproject.toml"
5✔
760
            if paths == "." and " -c " not in command:
5✔
761
                paths = cls.get_package_name()
×
762
            command += f" -r {paths}"
5✔
763
            cmd += " && " + command
5✔
764
        return cmd
5✔
765

766
    def gen(self: Self) -> str:
5✔
767
        paths = " ".join(map(str, self.args)) if self.args else "."
5✔
768
        return self.to_cmd(
5✔
769
            paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy
770
        )
771

772

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

776

777
def lint(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> None:
5✔
778
    if files is None:
5✔
779
        files = parse_files(sys.argv[1:])
5✔
780
    if files and files[0] == "lint":
5✔
781
        files = files[1:]
5✔
782
    LintCode(files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy).run()
5✔
783

784

785
def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> None:
5✔
786
    LintCode(
5✔
787
        files,
788
        check_only=True,
789
        _exit=True,
790
        dry=dry,
791
        bandit=bandit,
792
        skip_mypy=skip_mypy,
793
        dmypy=dmypy,
794
    ).run()
795

796

797
@cli.command(name="lint")
5✔
798
def make_style(
5✔
799
    files: Optional[list[Path]] = typer.Argument(default=None),  # noqa:B008
800
    check_only: bool = Option(False, "--check-only", "-c"),
801
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
802
    skip_mypy: bool = Option(False, "--skip-mypy"),
803
    use_dmypy: bool = Option(
804
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
805
    ),
806
    dry: bool = DryOption,
807
) -> None:
808
    """Run: ruff check/format to reformat code and then mypy to check"""
809
    if getattr(files, "default", files) is None:
5✔
810
        files = [Path(".")]
5✔
811
    elif isinstance(files, str):
5✔
812
        files = [files]
5✔
813
    skip = _ensure_bool(skip_mypy)
5✔
814
    dmypy = _ensure_bool(use_dmypy)
5✔
815
    bandit = _ensure_bool(bandit)
5✔
816
    if _ensure_bool(check_only):
5✔
817
        check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit)
5✔
818
    else:
819
        lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit)
5✔
820

821

822
@cli.command(name="check")
5✔
823
def only_check(
5✔
824
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
825
    skip_mypy: bool = Option(False, "--skip-mypy"),
826
    dry: bool = DryOption,
827
) -> None:
828
    """Check code style without reformat"""
829
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
5✔
830

831

832
class Sync(DryRun):
5✔
833
    def __init__(self: Self, filename: str, extras: str, save: bool, dry=False) -> None:
5✔
834
        self.filename = filename
5✔
835
        self.extras = extras
5✔
836
        self._save = save
5✔
837
        super().__init__(dry=dry)
5✔
838

839
    def gen(self) -> str:
5✔
840
        extras, save = self.extras, self._save
5✔
841
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
842
        if not (tool := Project.get_manage_tool()):
5✔
843
            if should_remove or not is_venv():
5✔
844
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
845
            return f"python -m pip install -r {self.filename}"
5✔
846
        prefix = "" if is_venv() else f"{tool} run "
5✔
847
        ensurepip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
848
        if tool == "uv":
5✔
849
            export_cmd = "uv export --no-hashes --all-extras --frozen"
5✔
850
            if check_call(prefix + "python -m pip --version"):
5✔
851
                ensurepip = ""
5✔
852
        elif tool in ("poetry", "pdm"):
5✔
853
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
854
            if tool == "poetry":
5✔
855
                ensurepip = ""
5✔
856
                if not UpgradeDependencies.should_with_dev():
5✔
857
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
858
                if extras and isinstance(extras, (str, list)):
5✔
859
                    export_cmd += f" --{extras=}".replace("'", '"')
5✔
860
            elif check_call(prefix + "python -m pip --version"):
5✔
861
                ensurepip = ""
5✔
862
        install_cmd = (
5✔
863
            f"{{2}} -o {{0}} &&{ensurepip} {{1}}python -m pip install -r {{0}}"
864
        )
865
        if should_remove and not save:
5✔
866
            install_cmd += " && rm -f {0}"
5✔
867
        return install_cmd.format(self.filename, prefix, export_cmd)
5✔
868

869

870
@cli.command()
5✔
871
def sync(
5✔
872
    filename="dev_requirements.txt",
873
    extras: str = Option("", "--extras", "-E"),
874
    save: bool = Option(
875
        False, "--save", "-s", help="Whether save the requirement file"
876
    ),
877
    dry: bool = DryOption,
878
) -> None:
879
    """Export dependencies by poetry to a txt file then install by pip."""
880
    Sync(filename, extras, save, dry=dry).run()
5✔
881

882

883
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
884
    for name in ("test.sh", "test.py"):
5✔
885
        if (file := path / name).exists():
5✔
886
            return file
5✔
887
    return None
5✔
888

889

890
def test(dry: bool, ignore_script=False) -> None:
5✔
891
    cwd = Path.cwd()
5✔
892
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
893
    script_dir = root / "scripts"
5✔
894
    if not _ensure_bool(ignore_script) and (
5✔
895
        test_script := _should_run_test_script(script_dir)
896
    ):
897
        cmd = f"{os.path.relpath(test_script, root)}"
5✔
898
        if cwd != root:
5✔
899
            cmd = f"cd {root} && " + cmd
5✔
900
    else:
901
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
902
        if not is_venv() or not check_call("coverage --version"):
5✔
903
            sep = " && "
5✔
904
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
905
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
906
    exit_if_run_failed(cmd, dry=dry)
5✔
907

908

909
@cli.command(name="test")
5✔
910
def coverage_test(
5✔
911
    dry: bool = DryOption,
912
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
913
) -> None:
914
    """Run unittest by pytest and report coverage"""
915
    return test(dry, ignore_script)
5✔
916

917

918
class Publish:
5✔
919
    class CommandEnum(StrEnum):
5✔
920
        poetry = "poetry publish --build"
5✔
921
        pdm = "pdm publish"
5✔
922
        uv = "uv build && uv publish"
5✔
923
        twine = "python -m build && twine upload"
5✔
924

925
    @classmethod
5✔
926
    def gen(cls) -> str:
5✔
927
        if tool := Project.get_manage_tool():
5✔
928
            return cls.CommandEnum[tool]
5✔
929
        return cls.CommandEnum.twine
5✔
930

931

932
@cli.command()
5✔
933
def upload(
5✔
934
    dry: bool = DryOption,
935
) -> None:
936
    """Shortcut for package publish"""
937
    cmd = Publish.gen()
5✔
938
    exit_if_run_failed(cmd, dry=dry)
5✔
939

940

941
def dev(
5✔
942
    port: int | None | OptionInfo,
943
    host: str | None | OptionInfo,
944
    file: str | None | ArgumentInfo = None,
945
    dry=False,
946
) -> None:
947
    cmd = "fastapi dev"
5✔
948
    no_port_yet = True
5✔
949
    if file is not None:
5✔
950
        try:
5✔
951
            port = int(str(file))  # type:ignore[arg-type]
5✔
952
        except ValueError:
5✔
953
            cmd += f" {file}"
5✔
954
        else:
955
            if port != 8000:
5✔
956
                cmd += f" --port={port}"
5✔
957
                no_port_yet = False
5✔
958
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
959
        cmd += f" --port={port}"
5✔
960
    if (host := getattr(host, "default", host)) and host not in (
5✔
961
        "localhost",
962
        "127.0.0.1",
963
    ):
964
        cmd += f" --host={host}"
5✔
965
    exit_if_run_failed(cmd, dry=dry)
5✔
966

967

968
@cli.command(name="dev")
5✔
969
def runserver(
5✔
970
    file_or_port: Optional[str] = typer.Argument(default=None),
971
    port: Optional[int] = Option(None, "-p", "--port"),
972
    host: Optional[str] = Option(None, "-h", "--host"),
973
    dry: bool = DryOption,
974
) -> None:
975
    """Start a fastapi server(only for fastapi>=0.111.0)"""
976
    if getattr(file_or_port, "default", file_or_port):
5✔
977
        dev(port, host, file=file_or_port, dry=dry)
5✔
978
    else:
979
        dev(port, host, dry=dry)
5✔
980

981

982
def main() -> None:
5✔
983
    cli()
5✔
984

985

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