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

waketzheng / fast-dev-cli / 15364627032

31 May 2025 02:27PM UTC coverage: 98.688% (-1.3%) from 100.0%
15364627032

push

github

waketzheng
feat: bump up support hatch dynamic version

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

9 existing lines in 1 file now uncovered.

677 of 686 relevant lines covered (98.69%)

4.93 hits per line

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

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

3
import contextlib
5✔
4
import importlib.metadata as importlib_metadata
5✔
5
import os
5✔
6
import re
5✔
7
import shlex
5✔
8
import subprocess  # nosec:B404
5✔
9
import sys
5✔
10
from functools import cached_property
5✔
11
from pathlib import Path
5✔
12
from typing import (
5✔
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
5✔
22
import typer
5✔
23
from typer import Exit, Option, echo, secho
5✔
24
from typer.models import ArgumentInfo, OptionInfo
5✔
25

26
try:
5✔
27
    from . import __version__
5✔
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()
5✔
49
DryOption = Option(False, "--dry", help="Only print, not really run shell command")
5✔
50
TOML_FILE = "pyproject.toml"
5✔
51
ToolName = Literal["poetry", "pdm", "uv"]
5✔
52
ToolOption = Option(
5✔
53
    "auto", "--tool", help="Explicit declare manage tool (default to auto detect)"
54
)
55

56

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

59

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

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

66

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

77

78
def is_venv() -> bool:
5✔
79
    """Whether in a virtual environment(also work for poetry)"""
80
    return hasattr(sys, "real_prefix") or (
5✔
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]:
5✔
86
    if isinstance(cmd, str):
5✔
87
        kw.setdefault("shell", True)
5✔
88
    return subprocess.run(cmd, **kw)  # nosec:B603
5✔
89

90

91
def run_and_echo(
5✔
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:
5✔
96
        echo(f"--> {cmd}")
5✔
97
    if dry:
5✔
98
        return 0
5✔
99
    return _run_shell(cmd, **kw).returncode
5✔
100

101

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

106

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

117

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

121

122
def read_version_from_file(
5✔
123
    package_name: str, work_dir: Path | None = None, toml_text: str | None = None
124
) -> str:
125
    if toml_text is None:
5✔
126
        toml_text = Project.load_toml_text()
5✔
127
    pattern = re.compile(r"version\s*=")
5✔
128
    invalid = ("0", "0.0.0")
5✔
129
    for line in toml_text.splitlines():
5✔
130
        if pattern.match(line):
5✔
131
            lib_version = _parse_version(line, pattern)
5✔
132
            if lib_version.startswith("{") or lib_version in invalid:
5✔
133
                break
5✔
134
            return lib_version
5✔
135
    if work_dir is None:
5✔
136
        work_dir = Project.get_work_dir()
5✔
137
    package_dir = work_dir / package_name
5✔
138
    if (
5✔
139
        not (init_file := package_dir / "__init__.py").exists()
140
        and not (init_file := work_dir / "src" / package_name / init_file.name).exists()
141
        and not (init_file := work_dir / "app" / init_file.name).exists()
142
    ):
143
        secho("WARNING: __init__.py file does not exist!")
5✔
144
        return "0.0.0"
5✔
145
    pattern = re.compile(r"__version__\s*=")
5✔
146
    for line in init_file.read_text("utf-8").splitlines():
5✔
147
        if pattern.match(line):
5✔
148
            return _parse_version(line, pattern)
5✔
149
    secho(f"WARNING: can not find '__version__' var in {init_file}!")
5✔
150
    return "0.0.0"
5✔
151

152

153
def get_current_version(
5✔
154
    verbose: bool = False,
155
    is_poetry: bool | None = None,
156
    package_name: str | None = None,
157
) -> str:
158
    if is_poetry is None:
5✔
159
        is_poetry = Project.manage_by_poetry()
5✔
160
    if not is_poetry:
5✔
161
        work_dir = None
5✔
162
        if package_name is None:
5✔
163
            work_dir = Project.get_work_dir()
5✔
164
            package_name = re.sub(r"[- ]", "_", work_dir.name)
5✔
165
        try:
5✔
166
            return importlib_metadata.version(package_name)
5✔
167
        except importlib_metadata.PackageNotFoundError:
5✔
168
            return read_version_from_file(package_name, work_dir)
5✔
169

170
    cmd = ["poetry", "version", "-s"]
5✔
171
    if verbose:
5✔
172
        echo(f"--> {' '.join(cmd)}")
5✔
173
    if out := capture_cmd_output(cmd, raises=True):
5✔
174
        out = out.splitlines()[-1].strip().split()[-1]
5✔
175
    return out
5✔
176

177

178
def _ensure_bool(value: bool | OptionInfo) -> bool:
5✔
179
    if not isinstance(value, bool):
5✔
180
        value = getattr(value, "default", False)
5✔
181
    return value
5✔
182

183

184
def _ensure_str(value: str | OptionInfo) -> str:
5✔
185
    if not isinstance(value, str):
5✔
186
        value = getattr(value, "default", "")
5✔
187
    return value
5✔
188

189

190
def exit_if_run_failed(
5✔
191
    cmd: str,
192
    env: dict[str, str] | None = None,
193
    _exit: bool = False,
194
    dry: bool = False,
195
    **kw: Any,
196
) -> subprocess.CompletedProcess[str]:
197
    run_and_echo(cmd, dry=True)
5✔
198
    if _ensure_bool(dry):
5✔
199
        return subprocess.CompletedProcess("", 0)
5✔
200
    if env is not None:
5✔
201
        env = {**os.environ, **env}
5✔
202
    r = _run_shell(cmd, env=env, **kw)
5✔
203
    if rc := r.returncode:
5✔
204
        if _exit:
5✔
205
            sys.exit(rc)
5✔
206
        raise Exit(rc)
5✔
207
    return r
5✔
208

209

210
class DryRun:
5✔
211
    def __init__(self: Self, _exit: bool = False, dry: bool = False) -> None:
5✔
212
        self.dry = dry
5✔
213
        self._exit = _exit
5✔
214

215
    def gen(self: Self) -> str:
5✔
216
        raise NotImplementedError
5✔
217

218
    def run(self: Self) -> None:
5✔
219
        exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
5✔
220

221

222
class BumpUp(DryRun):
5✔
223
    class PartChoices(StrEnum):
5✔
224
        patch = "patch"
5✔
225
        minor = "minor"
5✔
226
        major = "major"
5✔
227

228
    def __init__(
5✔
229
        self: Self,
230
        commit: bool,
231
        part: str,
232
        filename: str | None = None,
233
        dry: bool = False,
234
    ) -> None:
235
        self.commit = commit
5✔
236
        self.part = part
5✔
237
        if filename is None:
5✔
238
            filename = self.parse_filename()
5✔
239
        self.filename = filename
5✔
240
        super().__init__(dry=dry)
5✔
241

242
    @staticmethod
5✔
243
    def get_last_commit_message(raises: bool = False) -> str:
5✔
244
        cmd = 'git show --pretty=format:"%s" -s HEAD'
5✔
245
        return capture_cmd_output(cmd, raises=raises)
5✔
246

247
    @classmethod
5✔
248
    def should_add_emoji(cls) -> bool:
5✔
249
        """
250
        If last commit message is startswith emoji,
251
        add a ⬆️ flag at the prefix of bump up commit message.
252
        """
253
        try:
5✔
254
            first_char = cls.get_last_commit_message(raises=True)[0]
5✔
255
        except (IndexError, ShellCommandError):
5✔
256
            return False
5✔
257
        else:
258
            return emoji.is_emoji(first_char)
5✔
259

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

337
        return TOML_FILE
5✔
338

339
    def get_part(self, s: str) -> str:
5✔
340
        choices: dict[str, str] = {}
5✔
341
        for i, p in enumerate(self.PartChoices, 1):
5✔
342
            v = str(p)
5✔
343
            choices.update({str(i): v, v: v})
5✔
344
        try:
5✔
345
            return choices[s]
5✔
346
        except KeyError as e:
5✔
347
            echo(f"Invalid part: {s!r}")
5✔
348
            raise Exit(1) from e
5✔
349

350
    def gen(self: Self) -> str:
5✔
351
        _version = get_current_version()
5✔
352
        filename = self.filename
5✔
353
        echo(f"Current version(@{filename}): {_version}")
5✔
354
        if self.part:
5✔
355
            part = self.get_part(self.part)
5✔
356
        else:
357
            part = "patch"
5✔
358
            if a := input("Which one?").strip():
5✔
359
                part = self.get_part(a)
5✔
360
        self.part = part
5✔
361
        parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
5✔
362
        cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
5✔
363
        if self.commit:
5✔
364
            if part != "patch":
5✔
365
                cmd += " --tag"
5✔
366
            cmd += " --commit"
5✔
367
            if self.should_add_emoji():
5✔
368
                cmd += " --message-emoji=1"
5✔
369
            if not load_bool("DONT_GIT_PUSH"):
5✔
370
                cmd += " && git push && git push --tags && git log -1"
5✔
371
        else:
372
            cmd += " --allow-dirty"
5✔
373
        return cmd
5✔
374

375
    def run(self: Self) -> None:
5✔
376
        super().run()
5✔
377
        if not self.commit and not self.dry:
5✔
378
            new_version = get_current_version(True)
5✔
379
            echo(new_version)
5✔
380
            if self.part != "patch":
5✔
381
                echo("You may want to pin tag by `fast tag`")
5✔
382

383

384
@cli.command()
5✔
385
def version() -> None:
5✔
386
    """Show the version of this tool"""
387
    echo(f"Fast Dev Cli version: {__version__}")
5✔
388

389

390
@cli.command(name="bump")
5✔
391
def bump_version(
5✔
392
    part: BumpUp.PartChoices,
393
    commit: bool = Option(
394
        False, "--commit", "-c", help="Whether run `git commit` after version changed"
395
    ),
396
    dry: bool = DryOption,
397
) -> None:
398
    """Bump up version string in pyproject.toml"""
399
    return BumpUp(_ensure_bool(commit), getattr(part, "value", part), dry=dry).run()
5✔
400

401

402
def bump() -> None:
5✔
403
    part, commit = "", False
5✔
404
    if args := sys.argv[2:]:
5✔
405
        if "-c" in args or "--commit" in args:
5✔
406
            commit = True
5✔
407
        for a in args:
5✔
408
            if not a.startswith("-"):
5✔
409
                part = a
5✔
410
                break
5✔
411
    return BumpUp(commit, part, dry="--dry" in args).run()
5✔
412

413

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

417

418
class Project:
5✔
419
    path_depth = 5
5✔
420

421
    @staticmethod
5✔
422
    def is_poetry_v2(text: str) -> bool:
5✔
423
        return 'build-backend = "poetry' in text
5✔
424

425
    @staticmethod
5✔
426
    def work_dir(
5✔
427
        name: str, parent: Path, depth: int, be_file: bool = False
428
    ) -> Path | None:
429
        for _ in range(depth):
5✔
430
            if (f := parent.joinpath(name)).exists():
5✔
431
                if be_file:
5✔
432
                    return f
5✔
433
                return parent
5✔
434
            parent = parent.parent
5✔
435
        return None
5✔
436

437
    @classmethod
5✔
438
    def get_work_dir(
5✔
439
        cls: type[Self],
440
        name: str = TOML_FILE,
441
        cwd: Path | None = None,
442
        allow_cwd: bool = False,
443
        be_file: bool = False,
444
    ) -> Path:
445
        cwd = cwd or Path.cwd()
5✔
446
        if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
5✔
447
            return d
5✔
448
        if allow_cwd:
5✔
449
            return cls.get_root_dir(cwd)
5✔
450
        raise EnvError(f"{name} not found! Make sure this is a poetry project.")
5✔
451

452
    @classmethod
5✔
453
    def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
5✔
454
        toml_file = cls.get_work_dir(name, be_file=True)
5✔
455
        return toml_file.read_text("utf8")
5✔
456

457
    @classmethod
5✔
458
    def manage_by_poetry(cls: type[Self]) -> bool:
5✔
459
        return cls.get_manage_tool() == "poetry"
5✔
460

461
    @classmethod
5✔
462
    def get_manage_tool(cls: type[Self]) -> ToolName | None:
5✔
463
        try:
5✔
464
            text = cls.load_toml_text()
5✔
465
        except EnvError:
5✔
466
            pass
5✔
467
        else:
468
            for name in get_args(ToolName):
5✔
469
                if f"[tool.{name}]" in text:
5✔
470
                    return cast(ToolName, name)
5✔
471
            # Poetry 2.0 default to not include the '[tool.poetry]' section
472
            if cls.is_poetry_v2(text):
5✔
473
                return "poetry"
5✔
474
        return None
5✔
475

476
    @staticmethod
5✔
477
    def python_exec_dir() -> Path:
5✔
478
        return Path(sys.executable).parent
5✔
479

480
    @classmethod
5✔
481
    def get_root_dir(cls: type[Self], cwd: Path | None = None) -> Path:
5✔
482
        root = cwd or Path.cwd()
5✔
483
        venv_parent = cls.python_exec_dir().parent.parent
5✔
484
        if root.is_relative_to(venv_parent):
5✔
485
            root = venv_parent
5✔
486
        return root
5✔
487

488

489
class ParseError(Exception):
5✔
490
    """Raise this if parse dependence line error"""
491

492
    pass
5✔
493

494

495
class UpgradeDependencies(Project, DryRun):
5✔
496
    def __init__(
5✔
497
        self: Self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
498
    ) -> None:
499
        super().__init__(_exit, dry)
5✔
500
        self._tool = tool
5✔
501

502
    class DevFlag(StrEnum):
5✔
503
        new = "[tool.poetry.group.dev.dependencies]"
5✔
504
        old = "[tool.poetry.dev-dependencies]"
5✔
505

506
    @staticmethod
5✔
507
    def parse_value(version_info: str, key: str) -> str:
5✔
508
        """Pick out the value for key in version info.
509

510
        Example::
511
            >>> s= 'typer = {extras = ["all"], version = "^0.9.0", optional = true}'
512
            >>> UpgradeDependencies.parse_value(s, 'extras')
513
            'all'
514
            >>> UpgradeDependencies.parse_value(s, 'optional')
515
            'true'
516
            >>> UpgradeDependencies.parse_value(s, 'version')
517
            '^0.9.0'
518
        """
519
        sep = key + " = "
5✔
520
        rest = version_info.split(sep, 1)[-1].strip(" =")
5✔
521
        if rest.startswith("["):
5✔
522
            rest = rest[1:].split("]")[0]
5✔
523
        elif rest.startswith('"'):
5✔
524
            rest = rest[1:].split('"')[0]
5✔
525
        else:
526
            rest = rest.split(",")[0].split("}")[0]
5✔
527
        return rest.strip().replace('"', "")
5✔
528

529
    @staticmethod
5✔
530
    def no_need_upgrade(version_info: str, line: str) -> bool:
5✔
531
        if (v := version_info.replace(" ", "")).startswith("{url="):
5✔
532
            echo(f"No need to upgrade for: {line}")
5✔
533
            return True
5✔
534
        if (f := "version=") in v:
5✔
535
            v = v.split(f)[1].strip('"').split('"')[0]
5✔
536
        if v == "*":
5✔
537
            echo(f"Skip wildcard line: {line}")
5✔
538
            return True
5✔
539
        elif v == "[":
5✔
540
            echo(f"Skip complex dependence: {line}")
5✔
541
            return True
5✔
542
        elif v.startswith(">") or v.startswith("<") or v[0].isdigit():
5✔
543
            echo(f"Ignore bigger/smaller/equal: {line}")
5✔
544
            return True
5✔
545
        return False
5✔
546

547
    @classmethod
5✔
548
    def build_args(
5✔
549
        cls: type[Self], package_lines: list[str]
550
    ) -> tuple[list[str], dict[str, list[str]]]:
551
        args: list[str] = []  # ['typer[all]', 'fastapi']
5✔
552
        specials: dict[str, list[str]] = {}  # {'--platform linux': ['gunicorn']}
5✔
553
        for no, line in enumerate(package_lines, 1):
5✔
554
            if (
5✔
555
                not (m := line.strip())
556
                or m.startswith("#")
557
                or m == "]"
558
                or (m.startswith("{") and m.strip(",").endswith("}"))
559
            ):
560
                continue
5✔
561
            try:
5✔
562
                package, version_info = m.split("=", 1)
5✔
563
            except ValueError as e:
5✔
564
                raise ParseError(f"Failed to separate by '='@line {no}: {m}") from e
5✔
565
            if (package := package.strip()).lower() == "python":
5✔
566
                continue
5✔
567
            if cls.no_need_upgrade(version_info := version_info.strip(' "'), line):
5✔
568
                continue
5✔
569
            if (extras_tip := "extras") in version_info:
5✔
570
                package += "[" + cls.parse_value(version_info, extras_tip) + "]"
5✔
571
            item = f'"{package}@latest"'
5✔
572
            key = None
5✔
573
            if (pf := "platform") in version_info:
5✔
574
                platform = cls.parse_value(version_info, pf)
5✔
575
                key = f"--{pf}={platform}"
5✔
576
            if (sc := "source") in version_info:
5✔
577
                source = cls.parse_value(version_info, sc)
5✔
578
                key = ("" if key is None else (key + " ")) + f"--{sc}={source}"
5✔
579
            if "optional = true" in version_info:
5✔
580
                key = ("" if key is None else (key + " ")) + "--optional"
5✔
581
            if key is not None:
5✔
582
                specials[key] = specials.get(key, []) + [item]
5✔
583
            else:
584
                args.append(item)
5✔
585
        return args, specials
5✔
586

587
    @classmethod
5✔
588
    def should_with_dev(cls: type[Self]) -> bool:
5✔
589
        text = cls.load_toml_text()
5✔
590
        return cls.DevFlag.new in text or cls.DevFlag.old in text
5✔
591

592
    @staticmethod
5✔
593
    def parse_item(toml_str: str) -> list[str]:
5✔
594
        lines: list[str] = []
5✔
595
        for line in toml_str.splitlines():
5✔
596
            if (line := line.strip()).startswith("["):
5✔
597
                if lines:
5✔
598
                    break
5✔
599
            elif line:
5✔
600
                lines.append(line)
5✔
601
        return lines
5✔
602

603
    @classmethod
5✔
604
    def get_args(
5✔
605
        cls: type[Self], toml_text: str | None = None
606
    ) -> tuple[list[str], list[str], list[list[str]], str]:
607
        if toml_text is None:
5✔
608
            toml_text = cls.load_toml_text()
5✔
609
        main_title = "[tool.poetry.dependencies]"
5✔
610
        if (no_main_deps := main_title not in toml_text) and not cls.is_poetry_v2(
5✔
611
            toml_text
612
        ):
613
            raise EnvError(
5✔
614
                f"{main_title} not found! Make sure this is a poetry project."
615
            )
616
        text = toml_text.split(main_title)[-1]
5✔
617
        dev_flag = "--group dev"
5✔
618
        new_flag, old_flag = cls.DevFlag.new, cls.DevFlag.old
5✔
619
        if (dev_title := getattr(new_flag, "value", new_flag)) not in text:
5✔
620
            dev_title = getattr(old_flag, "value", old_flag)  # For poetry<=1.2
5✔
621
            dev_flag = "--dev"
5✔
622
        others: list[list[str]] = []
5✔
623
        try:
5✔
624
            main_toml, dev_toml = text.split(dev_title)
5✔
625
        except ValueError:
5✔
626
            dev_toml = ""
5✔
627
            main_toml = text
5✔
628
        mains = [] if no_main_deps else cls.parse_item(main_toml)
5✔
629
        devs = cls.parse_item(dev_toml)
5✔
630
        prod_packs, specials = cls.build_args(mains)
5✔
631
        if specials:
5✔
632
            others.extend([[k] + v for k, v in specials.items()])
5✔
633
        dev_packs, specials = cls.build_args(devs)
5✔
634
        if specials:
5✔
635
            others.extend([[k] + v + [dev_flag] for k, v in specials.items()])
5✔
636
        return prod_packs, dev_packs, others, dev_flag
5✔
637

638
    @classmethod
5✔
639
    def gen_cmd(cls: type[Self]) -> str:
5✔
640
        main_args, dev_args, others, dev_flags = cls.get_args()
5✔
641
        return cls.to_cmd(main_args, dev_args, others, dev_flags)
5✔
642

643
    @staticmethod
5✔
644
    def to_cmd(
5✔
645
        main_args: list[str],
646
        dev_args: list[str],
647
        others: list[list[str]],
648
        dev_flags: str,
649
    ) -> str:
650
        command = "poetry add "
5✔
651
        _upgrade = ""
5✔
652
        if main_args:
5✔
653
            _upgrade = command + " ".join(main_args)
5✔
654
        if dev_args:
5✔
655
            if _upgrade:
5✔
656
                _upgrade += " && "
5✔
657
            _upgrade += command + dev_flags + " " + " ".join(dev_args)
5✔
658
        for single in others:
5✔
659
            _upgrade += f" && poetry add {' '.join(single)}"
5✔
660
        return _upgrade
5✔
661

662
    def gen(self: Self) -> str:
5✔
663
        if self._tool == "uv":
5✔
664
            return "uv lock --upgrade --verbose && uv sync --frozen"
5✔
665
        elif self._tool == "pdm":
5✔
666
            return "pdm update --verbose && pdm install"
5✔
667
        return self.gen_cmd() + " && poetry lock && poetry update"
5✔
668

669

670
@cli.command()
5✔
671
def upgrade(
5✔
672
    tool: str = ToolOption,
673
    dry: bool = DryOption,
674
) -> None:
675
    """Upgrade dependencies in pyproject.toml to latest versions"""
676
    if not (tool := _ensure_str(tool)) or tool == ToolOption.default:
5✔
677
        tool = Project.get_manage_tool() or "uv"
5✔
678
    if tool in get_args(ToolName):
5✔
679
        UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
5✔
680
    else:
681
        secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
5✔
682
        raise typer.Exit(1)
5✔
683

684

685
class GitTag(DryRun):
5✔
686
    def __init__(self: Self, message: str, dry: bool) -> None:
5✔
687
        self.message = message
5✔
688
        super().__init__(dry=dry)
5✔
689

690
    @staticmethod
5✔
691
    def has_v_prefix() -> bool:
5✔
692
        return "v" in capture_cmd_output("git tag")
5✔
693

694
    def should_push(self: Self) -> bool:
5✔
695
        return "git push" in self.git_status
5✔
696

697
    def gen(self: Self) -> str:
5✔
698
        _version = get_current_version(verbose=False)
5✔
699
        if self.has_v_prefix():
5✔
700
            # Add `v` at prefix to compare with bumpversion tool
701
            _version = "v" + _version
5✔
702
        cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
5✔
703
        if self.should_push():
5✔
704
            cmd += " && git push"
5✔
705
        return cmd
5✔
706

707
    @cached_property
5✔
708
    def git_status(self: Self) -> str:
5✔
709
        return capture_cmd_output("git status")
5✔
710

711
    def mark_tag(self: Self) -> bool:
5✔
712
        if not re.search(r"working (tree|directory) clean", self.git_status) and (
5✔
713
            "无文件要提交,干净的工作区" not in self.git_status
714
        ):
715
            run_and_echo("git status")
5✔
716
            echo("ERROR: Please run git commit to make sure working tree is clean!")
5✔
717
            return False
5✔
718
        return bool(super().run())
5✔
719

720
    def run(self: Self) -> None:
5✔
721
        if self.mark_tag() and not self.dry:
5✔
722
            echo("You may want to publish package:\n poetry publish --build")
5✔
723

724

725
@cli.command()
5✔
726
def tag(
5✔
727
    message: str = Option("", "-m", "--message"),
728
    dry: bool = DryOption,
729
) -> None:
730
    """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
731
    GitTag(message, dry=dry).run()
5✔
732

733

734
class LintCode(DryRun):
5✔
735
    def __init__(
5✔
736
        self: Self,
737
        args: list[str] | str | None,
738
        check_only: bool = False,
739
        _exit: bool = False,
740
        dry: bool = False,
741
        bandit: bool = False,
742
        skip_mypy: bool = False,
743
        dmypy: bool = False,
744
        tool: str = ToolOption.default,
745
    ) -> None:
746
        self.args = args
5✔
747
        self.check_only = check_only
5✔
748
        self._bandit = bandit
5✔
749
        self._skip_mypy = skip_mypy
5✔
750
        self._use_dmypy = dmypy
5✔
751
        self._tool = tool
5✔
752
        super().__init__(_exit, dry)
5✔
753

754
    @staticmethod
5✔
755
    def check_lint_tool_installed() -> bool:
5✔
756
        return check_call("ruff --version")
5✔
757

758
    @staticmethod
5✔
759
    def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
5✔
760
        return (
5✔
761
            paths == "."
762
            and any(t.startswith("mypy") for t in tools)
763
            and (use_dmypy or load_bool("FASTDEVCLI_DMYPY"))
764
        )
765

766
    @staticmethod
5✔
767
    def get_package_name() -> str:
5✔
768
        root = Project.get_work_dir(allow_cwd=True)
5✔
769
        module_name = root.name.replace("-", "_").replace(" ", "_")
5✔
770
        package_maybe = (module_name, "src")
5✔
771
        for name in package_maybe:
5✔
772
            if root.joinpath(name).is_dir():
5✔
773
                return name
5✔
774
        return "."
5✔
775

776
    @classmethod
5✔
777
    def to_cmd(
5✔
778
        cls: type[Self],
779
        paths: str = ".",
780
        check_only: bool = False,
781
        bandit: bool = False,
782
        skip_mypy: bool = False,
783
        use_dmypy: bool = False,
784
        tool: str = ToolOption.default,
785
    ) -> str:
786
        if paths != "." and all(i.endswith(".html") for i in paths.split()):
5✔
787
            return f"prettier -w {paths}"
5✔
788
        cmd = ""
5✔
789
        tools = ["ruff format", "ruff check --extend-select=I,B,SIM --fix", "mypy"]
5✔
790
        if check_only:
5✔
791
            tools[0] += " --check"
5✔
792
        if check_only or load_bool("NO_FIX"):
5✔
793
            tools[1] = tools[1].replace(" --fix", "")
5✔
794
        if skip_mypy or load_bool("SKIP_MYPY") or load_bool("FASTDEVCLI_NO_MYPY"):
5✔
795
            # Sometimes mypy is too slow
796
            tools = tools[:-1]
5✔
797
        elif load_bool("IGNORE_MISSING_IMPORTS"):
5✔
798
            tools[-1] += " --ignore-missing-imports"
5✔
799
        lint_them = " && ".join(
5✔
800
            "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
801
        )
802
        prefix = ""
5✔
803
        should_run_by_tool = False
5✔
804
        if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(".local/bin"):
5✔
805
            if not cls.check_lint_tool_installed():
5✔
806
                should_run_by_tool = True
5✔
807
                if check_call('python -c "import fast_dev_cli"'):
5✔
808
                    command = 'python -m pip install -U "fast-dev-cli"'
5✔
809
                    tip = "You may need to run following command to install lint tools:"
5✔
810
                    secho(f"{tip}\n\n  {command}\n", fg="yellow")
5✔
811
        else:
812
            should_run_by_tool = True
5✔
813
        if should_run_by_tool and tool:
5✔
814
            if tool == ToolOption.default:
5✔
815
                tool = Project.get_manage_tool() or ""
5✔
816
            if tool:
5✔
817
                prefix = tool + " run "
5✔
818
        if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
5✔
819
            tools[-1] = "dmypy run"
5✔
820
        cmd += lint_them.format(prefix, paths, *tools)
5✔
821
        if bandit or load_bool("FASTDEVCLI_BANDIT"):
5✔
822
            command = prefix + "bandit"
5✔
823
            if Path("pyproject.toml").exists():
5✔
824
                toml_text = Project.load_toml_text()
5✔
825
                if "[tool.bandit" in toml_text:
5✔
826
                    command += " -c pyproject.toml"
5✔
827
            if paths == "." and " -c " not in command:
5✔
828
                paths = cls.get_package_name()
5✔
829
            command += f" -r {paths}"
5✔
830
            cmd += " && " + command
5✔
831
        return cmd
5✔
832

833
    def gen(self: Self) -> str:
5✔
834
        if isinstance(args := self.args, str):
5✔
835
            args = args.split()
5✔
836
        paths = " ".join(map(str, args)) if args else "."
5✔
837
        return self.to_cmd(
5✔
838
            paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy
839
        )
840

841

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

845

846
def lint(
5✔
847
    files: list[str] | str | None = None,
848
    dry: bool = False,
849
    bandit: bool = False,
850
    skip_mypy: bool = False,
851
    dmypy: bool = False,
852
    tool: str = ToolOption.default,
853
) -> None:
854
    if files is None:
5✔
855
        files = parse_files(sys.argv[1:])
5✔
856
    if files and files[0] == "lint":
5✔
857
        files = files[1:]
5✔
858
    LintCode(
5✔
859
        files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy, tool=tool
860
    ).run()
861

862

863
def check(
5✔
864
    files: list[str] | str | None = None,
865
    dry: bool = False,
866
    bandit: bool = False,
867
    skip_mypy: bool = False,
868
    dmypy: bool = False,
869
    tool: str = ToolOption.default,
870
) -> None:
871
    LintCode(
5✔
872
        files,
873
        check_only=True,
874
        _exit=True,
875
        dry=dry,
876
        bandit=bandit,
877
        skip_mypy=skip_mypy,
878
        dmypy=dmypy,
879
        tool=tool,
880
    ).run()
881

882

883
@cli.command(name="lint")
5✔
884
def make_style(
5✔
885
    files: Optional[list[str]] = typer.Argument(default=None),  # noqa:B008
886
    check_only: bool = Option(False, "--check-only", "-c"),
887
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
888
    skip_mypy: bool = Option(False, "--skip-mypy"),
889
    use_dmypy: bool = Option(
890
        False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
891
    ),
892
    tool: str = ToolOption,
893
    dry: bool = DryOption,
894
) -> None:
895
    """Run: ruff check/format to reformat code and then mypy to check"""
896
    if getattr(files, "default", files) is None:
5✔
897
        files = ["."]
5✔
898
    elif isinstance(files, str):
5✔
899
        files = [files]
5✔
900
    skip = _ensure_bool(skip_mypy)
5✔
901
    dmypy = _ensure_bool(use_dmypy)
5✔
902
    bandit = _ensure_bool(bandit)
5✔
903
    tool = _ensure_str(tool)
5✔
904
    if _ensure_bool(check_only):
5✔
905
        check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
5✔
906
    else:
907
        lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
5✔
908

909

910
@cli.command(name="check")
5✔
911
def only_check(
5✔
912
    bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
913
    skip_mypy: bool = Option(False, "--skip-mypy"),
914
    dry: bool = DryOption,
915
) -> None:
916
    """Check code style without reformat"""
917
    check(dry=dry, bandit=bandit, skip_mypy=_ensure_bool(skip_mypy))
5✔
918

919

920
class Sync(DryRun):
5✔
921
    def __init__(
5✔
922
        self: Self, filename: str, extras: str, save: bool, dry: bool = False
923
    ) -> None:
924
        self.filename = filename
5✔
925
        self.extras = extras
5✔
926
        self._save = save
5✔
927
        super().__init__(dry=dry)
5✔
928

929
    def gen(self) -> str:
5✔
930
        extras, save = self.extras, self._save
5✔
931
        should_remove = not Path.cwd().joinpath(self.filename).exists()
5✔
932
        if not (tool := Project.get_manage_tool()):
5✔
933
            if should_remove or not is_venv():
5✔
934
                raise EnvError("There project is not managed by uv/pdm/poetry!")
5✔
935
            return f"python -m pip install -r {self.filename}"
5✔
936
        prefix = "" if is_venv() else f"{tool} run "
5✔
937
        ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
5✔
938
        export_cmd = "uv export --no-hashes --all-extras --frozen"
5✔
939
        if tool in ("poetry", "pdm"):
5✔
940
            export_cmd = f"{tool} export --without-hashes --with=dev"
5✔
941
            if tool == "poetry":
5✔
942
                ensure_pip = ""
5✔
943
                if not UpgradeDependencies.should_with_dev():
5✔
944
                    export_cmd = export_cmd.replace(" --with=dev", "")
5✔
945
                if extras and isinstance(extras, (str, list)):
5✔
946
                    export_cmd += f" --{extras=}".replace("'", '"')
5✔
947
            elif check_call(prefix + "python -m pip --version"):
5✔
948
                ensure_pip = ""
5✔
949
        elif check_call(prefix + "python -m pip --version"):
5✔
950
            ensure_pip = ""
5✔
951
        install_cmd = (
5✔
952
            f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
953
        )
954
        if should_remove and not save:
5✔
955
            install_cmd += " && rm -f {0}"
5✔
956
        return install_cmd.format(self.filename, prefix, export_cmd)
5✔
957

958

959
@cli.command()
5✔
960
def sync(
5✔
961
    filename: str = "dev_requirements.txt",
962
    extras: str = Option("", "--extras", "-E"),
963
    save: bool = Option(
964
        False, "--save", "-s", help="Whether save the requirement file"
965
    ),
966
    dry: bool = DryOption,
967
) -> None:
968
    """Export dependencies by poetry to a txt file then install by pip."""
969
    Sync(filename, extras, save, dry=dry).run()
5✔
970

971

972
def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
5✔
973
    for name in ("test.sh", "test.py"):
5✔
974
        if (file := path / name).exists():
5✔
975
            return file
5✔
976
    return None
5✔
977

978

979
def test(dry: bool, ignore_script: bool = False) -> None:
5✔
980
    cwd = Path.cwd()
5✔
981
    root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
5✔
982
    script_dir = root / "scripts"
5✔
983
    if not _ensure_bool(ignore_script) and (
5✔
984
        test_script := _should_run_test_script(script_dir)
985
    ):
986
        cmd = f"{os.path.relpath(test_script, root)}"
5✔
987
        if cwd != root:
5✔
988
            cmd = f"cd {root} && " + cmd
5✔
989
    else:
990
        cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
5✔
991
        if not is_venv() or not check_call("coverage --version"):
5✔
992
            sep = " && "
5✔
993
            prefix = f"{tool} run " if (tool := Project.get_manage_tool()) else ""
5✔
994
            cmd = sep.join(prefix + i for i in cmd.split(sep))
5✔
995
    exit_if_run_failed(cmd, dry=dry)
5✔
996

997

998
@cli.command(name="test")
5✔
999
def coverage_test(
5✔
1000
    dry: bool = DryOption,
1001
    ignore_script: bool = Option(False, "--ignore-script", "-i"),
1002
) -> None:
1003
    """Run unittest by pytest and report coverage"""
1004
    return test(dry, ignore_script)
5✔
1005

1006

1007
class Publish:
5✔
1008
    class CommandEnum(StrEnum):
5✔
1009
        poetry = "poetry publish --build"
5✔
1010
        pdm = "pdm publish"
5✔
1011
        uv = "uv build && uv publish"
5✔
1012
        twine = "python -m build && twine upload"
5✔
1013

1014
    @classmethod
5✔
1015
    def gen(cls) -> str:
5✔
1016
        if tool := Project.get_manage_tool():
5✔
1017
            return cls.CommandEnum[tool]
5✔
1018
        return cls.CommandEnum.twine
5✔
1019

1020

1021
@cli.command()
5✔
1022
def upload(
5✔
1023
    dry: bool = DryOption,
1024
) -> None:
1025
    """Shortcut for package publish"""
1026
    cmd = Publish.gen()
5✔
1027
    exit_if_run_failed(cmd, dry=dry)
5✔
1028

1029

1030
def dev(
5✔
1031
    port: int | None | OptionInfo,
1032
    host: str | None | OptionInfo,
1033
    file: str | None | ArgumentInfo = None,
1034
    dry: bool = False,
1035
) -> None:
1036
    cmd = "fastapi dev"
5✔
1037
    no_port_yet = True
5✔
1038
    if file is not None:
5✔
1039
        try:
5✔
1040
            port = int(str(file))
5✔
1041
        except ValueError:
5✔
1042
            cmd += f" {file}"
5✔
1043
        else:
1044
            if port != 8000:
5✔
1045
                cmd += f" --port={port}"
5✔
1046
                no_port_yet = False
5✔
1047
    if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
5✔
1048
        cmd += f" --port={port}"
5✔
1049
    if (host := getattr(host, "default", host)) and host not in (
5✔
1050
        "localhost",
1051
        "127.0.0.1",
1052
    ):
1053
        cmd += f" --host={host}"
5✔
1054
    exit_if_run_failed(cmd, dry=dry)
5✔
1055

1056

1057
@cli.command(name="dev")
5✔
1058
def runserver(
5✔
1059
    file_or_port: Optional[str] = typer.Argument(default=None),
1060
    port: Optional[int] = Option(None, "-p", "--port"),
1061
    host: Optional[str] = Option(None, "-h", "--host"),
1062
    dry: bool = DryOption,
1063
) -> None:
1064
    """Start a fastapi server(only for fastapi>=0.111.0)"""
1065
    if getattr(file_or_port, "default", file_or_port):
5✔
1066
        dev(port, host, file=file_or_port, dry=dry)
5✔
1067
    else:
1068
        dev(port, host, dry=dry)
5✔
1069

1070

1071
def main() -> None:
5✔
1072
    cli()
5✔
1073

1074

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