fast-dev-cli 0.16.2__tar.gz → 0.17.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/PKG-INFO +1 -1
  2. fast_dev_cli-0.17.1/fast_dev_cli/__init__.py +1 -0
  3. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/fast_dev_cli/cli.py +255 -81
  4. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/pyproject.toml +23 -24
  5. fast_dev_cli-0.17.1/scripts/deps.py +156 -0
  6. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/conftest.py +5 -3
  7. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_bump.py +53 -56
  8. fast_dev_cli-0.17.1/tests/test_exec.py +41 -0
  9. fast_dev_cli-0.17.1/tests/test_help.py +7 -0
  10. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_lint.py +0 -8
  11. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_poetry_version_plugin.py +21 -36
  12. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_tag.py +14 -6
  13. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_upgrade.py +2 -2
  14. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_version.py +20 -2
  15. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/utils.py +13 -21
  16. fast_dev_cli-0.16.2/fast_dev_cli/__init__.py +0 -1
  17. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/LICENSE +0 -0
  18. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/README.md +0 -0
  19. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/fast_dev_cli/__main__.py +0 -0
  20. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/fast_dev_cli/py.typed +0 -0
  21. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/pdm_build.py +0 -0
  22. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/scripts/check.py +0 -0
  23. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/scripts/format.py +0 -0
  24. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/scripts/test.py +0 -0
  25. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/__init__.py +0 -0
  26. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_fast_test.py +0 -0
  27. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_functions.py +0 -0
  28. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_runserver.py +0 -0
  29. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_sync.py +0 -0
  30. {fast_dev_cli-0.16.2 → fast_dev_cli-0.17.1}/tests/test_upload.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fast-dev-cli
3
- Version: 0.16.2
3
+ Version: 0.17.1
4
4
  Summary: Python project development tool.
5
5
  Author-Email: Waket Zheng <waketzheng@gmail.com>>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -0,0 +1 @@
1
+ __version__ = "0.17.1"
@@ -10,29 +10,20 @@ import sys
10
10
  from functools import cached_property
11
11
  from pathlib import Path
12
12
  from typing import (
13
+ TYPE_CHECKING,
13
14
  Any,
14
15
  Literal,
15
16
  # Optional is required by Option generated by typer
16
17
  Optional,
17
18
  cast,
18
19
  get_args,
20
+ overload,
19
21
  )
20
22
 
21
23
  import typer
22
24
  from typer import Exit, Option, echo, secho
23
25
  from typer.models import ArgumentInfo, OptionInfo
24
26
 
25
- try:
26
- from emoji import is_emoji
27
- except ImportError:
28
-
29
- def is_emoji(char: str) -> bool: # type:ignore[misc]
30
- return (
31
- not re.match(r"[\w\d\s]", char)
32
- and not "\u4e00" <= char <= "\u9fff" # Chinese
33
- )
34
-
35
-
36
27
  try:
37
28
  from . import __version__
38
29
  except ImportError: # pragma: no cover
@@ -42,20 +33,24 @@ except ImportError: # pragma: no cover
42
33
 
43
34
  if sys.version_info >= (3, 11): # pragma: no cover
44
35
  from enum import StrEnum
45
- from typing import Self
46
36
 
47
37
  import tomllib
48
38
  else: # pragma: no cover
49
39
  from enum import Enum
50
40
 
51
41
  import tomli as tomllib
52
- from typing_extensions import Self
53
42
 
54
43
  class StrEnum(str, Enum):
55
44
  __str__ = str.__str__
56
45
 
57
46
 
58
- cli = typer.Typer()
47
+ if TYPE_CHECKING:
48
+ if sys.version_info >= (3, 11):
49
+ from typing import Self
50
+ else:
51
+ from typing_extensions import Self
52
+
53
+ cli = typer.Typer(no_args_is_help=True)
59
54
  DryOption = Option(False, "--dry", help="Only print, not really run shell command")
60
55
  TOML_FILE = "pyproject.toml"
61
56
  ToolName = Literal["poetry", "pdm", "uv"]
@@ -64,7 +59,10 @@ ToolOption = Option(
64
59
  )
65
60
 
66
61
 
67
- class ShellCommandError(Exception): ...
62
+ class FastDevCliError(Exception): ...
63
+
64
+
65
+ class ShellCommandError(FastDevCliError): ...
68
66
 
69
67
 
70
68
  def poetry_module_name(name: str) -> str:
@@ -79,6 +77,18 @@ def poetry_module_name(name: str) -> str:
79
77
  return canonicalize_name(name).replace("-", "_").replace(" ", "_")
80
78
 
81
79
 
80
+ def is_emoji(char: str) -> bool:
81
+ try:
82
+ import emoji
83
+ except ImportError:
84
+ pass
85
+ else:
86
+ return emoji.is_emoji(char)
87
+ if re.match(r"[\w\d\s]", char):
88
+ return False
89
+ return not "\u4e00" <= char <= "\u9fff" # Chinese character
90
+
91
+
82
92
  def load_bool(name: str, default: bool = False) -> bool:
83
93
  if not (v := os.getenv(name)):
84
94
  return default
@@ -111,7 +121,10 @@ def run_and_echo(
111
121
  echo(f"--> {cmd}")
112
122
  if dry:
113
123
  return 0
114
- return _run_shell(cmd, **kw).returncode
124
+ command: list[str] | str = cmd
125
+ if "shell" not in kw and not (set(cmd) & {"|", ">", "&"}):
126
+ command = shlex.split(cmd)
127
+ return _run_shell(command, **kw).returncode
115
128
 
116
129
 
117
130
  def check_call(cmd: str) -> bool:
@@ -148,9 +161,9 @@ def read_version_from_file(
148
161
  toml_text = Project.load_toml_text()
149
162
  context = tomllib.loads(toml_text)
150
163
  with contextlib.suppress(KeyError):
151
- return context["project"]["version"]
164
+ return cast(str, context["project"]["version"])
152
165
  with contextlib.suppress(KeyError): # Poetry V1
153
- return context["tool"]["poetry"]["version"]
166
+ return cast(str, context["tool"]["poetry"]["version"])
154
167
  secho(f"WARNING: can not find 'version' item in {version_file}!")
155
168
  return "0.0.0"
156
169
  pattern = re.compile(r"__version__\s*=")
@@ -177,33 +190,61 @@ def read_version_from_file(
177
190
  return "0.0.0"
178
191
 
179
192
 
193
+ @overload
180
194
  def get_current_version(
181
195
  verbose: bool = False,
182
196
  is_poetry: bool | None = None,
183
197
  package_name: str | None = None,
184
- ) -> str:
185
- if is_poetry is None:
186
- is_poetry = Project.manage_by_poetry()
187
- if not is_poetry:
188
- work_dir = None
189
- if package_name is None:
190
- work_dir = Project.get_work_dir()
191
- package_name = re.sub(r"[- ]", "_", work_dir.name)
192
- try:
193
- installed_version = importlib_metadata.version(package_name)
194
- except importlib_metadata.PackageNotFoundError:
195
- ...
196
- else:
197
- if installed_version != "0.0.0":
198
- return installed_version
199
- return read_version_from_file(package_name, work_dir)
198
+ *,
199
+ check_version: Literal[False] = False,
200
+ ) -> str: ...
200
201
 
201
- cmd = ["poetry", "version", "-s"]
202
- if verbose:
203
- echo(f"--> {' '.join(cmd)}")
204
- if out := capture_cmd_output(cmd, raises=True):
205
- out = out.splitlines()[-1].strip().split()[-1]
206
- return out
202
+
203
+ @overload
204
+ def get_current_version(
205
+ verbose: bool = False,
206
+ is_poetry: bool | None = None,
207
+ package_name: str | None = None,
208
+ *,
209
+ check_version: Literal[True] = True,
210
+ ) -> tuple[bool, str]: ...
211
+
212
+
213
+ def get_current_version(
214
+ verbose: bool = False,
215
+ is_poetry: bool | None = None,
216
+ package_name: str | None = None,
217
+ *,
218
+ check_version: bool = False,
219
+ ) -> str | tuple[bool, str]:
220
+ if is_poetry is True or Project.manage_by_poetry():
221
+ cmd = ["poetry", "version", "-s"]
222
+ if verbose:
223
+ echo(f"--> {' '.join(cmd)}")
224
+ if out := capture_cmd_output(cmd, raises=True):
225
+ out = out.splitlines()[-1].strip().split()[-1]
226
+ if check_version:
227
+ return True, out
228
+ return out
229
+ toml_text = work_dir = None
230
+ if package_name is None:
231
+ work_dir = Project.get_work_dir()
232
+ toml_text = Project.load_toml_text()
233
+ doc = tomllib.loads(toml_text)
234
+ project_name = doc.get("project", {}).get("name", work_dir.name)
235
+ package_name = re.sub(r"[- ]", "_", project_name)
236
+ local_version = read_version_from_file(package_name, work_dir, toml_text)
237
+ try:
238
+ installed_version = importlib_metadata.version(package_name)
239
+ except importlib_metadata.PackageNotFoundError:
240
+ installed_version = ""
241
+ current_version = local_version or installed_version
242
+ if not current_version:
243
+ raise FastDevCliError(f"Failed to get current version of {package_name!r}")
244
+ if check_version:
245
+ is_conflict = bool(local_version) and local_version != installed_version
246
+ return is_conflict, current_version
247
+ return current_version
207
248
 
208
249
 
209
250
  def _ensure_bool(value: bool | OptionInfo) -> bool:
@@ -239,14 +280,14 @@ def exit_if_run_failed(
239
280
 
240
281
 
241
282
  class DryRun:
242
- def __init__(self: Self, _exit: bool = False, dry: bool = False) -> None:
283
+ def __init__(self, _exit: bool = False, dry: bool = False) -> None:
243
284
  self.dry = dry
244
285
  self._exit = _exit
245
286
 
246
- def gen(self: Self) -> str:
287
+ def gen(self) -> str:
247
288
  raise NotImplementedError
248
289
 
249
- def run(self: Self) -> None:
290
+ def run(self) -> None:
250
291
  exit_if_run_failed(self.gen(), _exit=self._exit, dry=self.dry)
251
292
 
252
293
 
@@ -257,17 +298,21 @@ class BumpUp(DryRun):
257
298
  major = "major"
258
299
 
259
300
  def __init__(
260
- self: Self,
301
+ self,
261
302
  commit: bool,
262
303
  part: str,
263
304
  filename: str | None = None,
264
305
  dry: bool = False,
306
+ no_sync: bool = False,
307
+ emoji: bool | None = None,
265
308
  ) -> None:
266
309
  self.commit = commit
267
310
  self.part = part
268
311
  if filename is None:
269
312
  filename = self.parse_filename()
270
313
  self.filename = filename
314
+ self._no_sync = no_sync
315
+ self._emoji = emoji
271
316
  super().__init__(dry=dry)
272
317
 
273
318
  @staticmethod
@@ -317,7 +362,9 @@ class BumpUp(DryRun):
317
362
  work_dir = Project.get_work_dir()
318
363
  for tool in ("pdm", "hatch"):
319
364
  with contextlib.suppress(KeyError):
320
- version_path = context["tool"][tool]["version"]["path"]
365
+ version_path = cast(
366
+ str, context["tool"][tool]["version"]["path"]
367
+ )
321
368
  if (
322
369
  Path(version_path).exists()
323
370
  or work_dir.joinpath(version_path).exists()
@@ -390,8 +437,8 @@ class BumpUp(DryRun):
390
437
  echo(f"Invalid part: {s!r}")
391
438
  raise Exit(1) from e
392
439
 
393
- def gen(self: Self) -> str:
394
- _version = get_current_version()
440
+ def gen(self) -> str:
441
+ should_sync, _version = get_current_version(check_version=True)
395
442
  filename = self.filename
396
443
  echo(f"Current version(@{filename}): {_version}")
397
444
  if self.part:
@@ -407,17 +454,17 @@ class BumpUp(DryRun):
407
454
  if part != "patch":
408
455
  cmd += " --tag"
409
456
  cmd += " --commit"
410
- if self.should_add_emoji():
457
+ if self._emoji or (self._emoji is None and self.should_add_emoji()):
411
458
  cmd += " --message-emoji=1"
412
459
  if not load_bool("DONT_GIT_PUSH"):
413
460
  cmd += " && git push && git push --tags && git log -1"
414
461
  else:
415
462
  cmd += " --allow-dirty"
416
- if Project.is_pdm_project():
417
- cmd = "pdm sync --prod && " + cmd
463
+ if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
464
+ cmd = f"{sync} && " + cmd
418
465
  return cmd
419
466
 
420
- def run(self: Self) -> None:
467
+ def run(self) -> None:
421
468
  super().run()
422
469
  if not self.commit and not self.dry:
423
470
  new_version = get_current_version(True)
@@ -429,7 +476,19 @@ class BumpUp(DryRun):
429
476
  @cli.command()
430
477
  def version() -> None:
431
478
  """Show the version of this tool"""
432
- echo(f"Fast Dev Cli version: {__version__}")
479
+ echo("Fast Dev Cli Version: " + typer.style(__version__, fg=typer.colors.BLUE))
480
+ with contextlib.suppress(FileNotFoundError, KeyError):
481
+ toml_text = Project.load_toml_text()
482
+ doc = tomllib.loads(toml_text)
483
+ version_file = doc["tool"]["pdm"]["version"]["path"]
484
+ text = Project.get_work_dir().joinpath(version_file).read_text()
485
+ varname = "__version__"
486
+ for line in text.splitlines():
487
+ if line.strip().startswith(varname):
488
+ value = line.split("=", 1)[-1].strip().strip('"').strip("'")
489
+ styled = typer.style(value, bold=True)
490
+ echo(f"Version value in {version_file}: " + styled)
491
+ break
433
492
 
434
493
 
435
494
  @cli.command(name="bump")
@@ -438,10 +497,24 @@ def bump_version(
438
497
  commit: bool = Option(
439
498
  False, "--commit", "-c", help="Whether run `git commit` after version changed"
440
499
  ),
500
+ emoji: Optional[bool] = Option(
501
+ None, "--emoji", help="Whether add emoji prefix to commit message"
502
+ ),
503
+ no_sync: bool = Option(
504
+ False, "--no-sync", help="Do not run sync command to update version"
505
+ ),
441
506
  dry: bool = DryOption,
442
507
  ) -> None:
443
508
  """Bump up version string in pyproject.toml"""
444
- return BumpUp(_ensure_bool(commit), getattr(part, "value", part), dry=dry).run()
509
+ if emoji is not None:
510
+ emoji = _ensure_bool(emoji)
511
+ return BumpUp(
512
+ _ensure_bool(commit),
513
+ getattr(part, "value", part),
514
+ no_sync=_ensure_bool(no_sync),
515
+ emoji=emoji,
516
+ dry=dry,
517
+ ).run()
445
518
 
446
519
 
447
520
  def bump() -> None:
@@ -453,7 +526,7 @@ def bump() -> None:
453
526
  if not a.startswith("-"):
454
527
  part = a
455
528
  break
456
- return BumpUp(commit, part, dry="--dry" in args).run()
529
+ return BumpUp(commit, part, no_sync="--no-sync" in args, dry="--dry" in args).run()
457
530
 
458
531
 
459
532
  class EnvError(Exception):
@@ -462,6 +535,7 @@ class EnvError(Exception):
462
535
 
463
536
  class Project:
464
537
  path_depth = 5
538
+ _tool: ToolName | None = None
465
539
 
466
540
  @staticmethod
467
541
  def is_poetry_v2(text: str) -> bool:
@@ -492,7 +566,7 @@ class Project:
492
566
  return d
493
567
  if allow_cwd:
494
568
  return cls.get_root_dir(cwd)
495
- raise EnvError(f"{name} not found! Make sure this is a poetry project.")
569
+ raise EnvError(f"{name} not found! Make sure this is a python project.")
496
570
 
497
571
  @classmethod
498
572
  def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
@@ -500,22 +574,40 @@ class Project:
500
574
  return toml_file.read_text("utf8")
501
575
 
502
576
  @classmethod
503
- def manage_by_poetry(cls: type[Self]) -> bool:
504
- return cls.get_manage_tool() == "poetry"
577
+ def manage_by_poetry(cls: type[Self], cache: bool = False) -> bool:
578
+ return cls.get_manage_tool(cache=cache) == "poetry"
505
579
 
506
580
  @classmethod
507
- def get_manage_tool(cls: type[Self]) -> ToolName | None:
581
+ def get_manage_tool(cls: type[Self], cache: bool = False) -> ToolName | None:
582
+ if cache and cls._tool:
583
+ return cls._tool
508
584
  try:
509
585
  text = cls.load_toml_text()
510
586
  except EnvError:
511
587
  pass
512
588
  else:
589
+ with contextlib.suppress(KeyError, tomllib.TOMLDecodeError):
590
+ doc = tomllib.loads(text)
591
+ backend = doc["build-system"]["build-backend"]
592
+ if "poetry" in backend:
593
+ cls._tool = "poetry"
594
+ return cls._tool
595
+ elif "pdm" in backend:
596
+ cls._tool = "pdm"
597
+ work_dir = cls.get_work_dir(allow_cwd=True)
598
+ if not Path(work_dir, "pdm.lock").exists() and (
599
+ "[tool.uv]" in text or Path(work_dir, "uv.lock").exists()
600
+ ):
601
+ cls._tool = "uv"
602
+ return cls._tool
513
603
  for name in get_args(ToolName):
514
604
  if f"[tool.{name}]" in text:
515
- return cast(ToolName, name)
605
+ cls._tool = cast(ToolName, name)
606
+ return cls._tool
516
607
  # Poetry 2.0 default to not include the '[tool.poetry]' section
517
608
  if cls.is_poetry_v2(text):
518
- return "poetry"
609
+ cls._tool = "poetry"
610
+ return cls._tool
519
611
  return None
520
612
 
521
613
  @staticmethod
@@ -531,14 +623,40 @@ class Project:
531
623
  return root
532
624
 
533
625
  @classmethod
534
- def is_pdm_project(cls, strict: bool = True) -> bool:
535
- if cls.get_manage_tool() != "pdm":
626
+ def is_pdm_project(cls, strict: bool = True, cache: bool = False) -> bool:
627
+ if cls.get_manage_tool(cache=cache) != "pdm":
536
628
  return False
537
629
  if strict:
538
630
  lock_file = cls.get_work_dir() / "pdm.lock"
539
631
  return lock_file.exists()
540
632
  return True
541
633
 
634
+ @classmethod
635
+ def get_sync_command(cls, prod: bool = True, doc: dict | None = None) -> str:
636
+ if cls.is_pdm_project():
637
+ return "pdm sync" + " --prod" * prod
638
+ elif cls.manage_by_poetry(cache=True):
639
+ cmd = "poetry install"
640
+ if prod:
641
+ if doc is None:
642
+ doc = tomllib.loads(cls.load_toml_text())
643
+ if doc.get("project", {}).get("dependencies") or any(
644
+ i != "python"
645
+ for i in doc.get("tool", {})
646
+ .get("poetry", {})
647
+ .get("dependencies", [])
648
+ ):
649
+ cmd += " --only=main"
650
+ return cmd
651
+ elif cls.get_manage_tool(cache=True) == "uv":
652
+ return "uv sync --inexact" + " --no-dev" * prod
653
+ return ""
654
+
655
+ @classmethod
656
+ def sync_dependencies(cls, prod: bool = True) -> None:
657
+ if cmd := cls.get_sync_command():
658
+ run_and_echo(cmd)
659
+
542
660
 
543
661
  class ParseError(Exception):
544
662
  """Raise this if parse dependence line error"""
@@ -548,7 +666,7 @@ class ParseError(Exception):
548
666
 
549
667
  class UpgradeDependencies(Project, DryRun):
550
668
  def __init__(
551
- self: Self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
669
+ self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
552
670
  ) -> None:
553
671
  super().__init__(_exit, dry)
554
672
  self._tool = tool
@@ -713,11 +831,13 @@ class UpgradeDependencies(Project, DryRun):
713
831
  _upgrade += f" && poetry add {' '.join(single)}"
714
832
  return _upgrade
715
833
 
716
- def gen(self: Self) -> str:
834
+ def gen(self) -> str:
717
835
  if self._tool == "uv":
718
- return "uv lock --upgrade --verbose && uv sync --frozen --all-groups"
836
+ up = "uv lock --upgrade --verbose"
837
+ deps = "uv sync --inexact --frozen --all-groups --all-extras"
838
+ return f"{up} && {deps}"
719
839
  elif self._tool == "pdm":
720
- return "pdm update --verbose && pdm sync -G :all"
840
+ return "pdm update --verbose && pdm sync -G :all --frozen"
721
841
  return self.gen_cmd() + " && poetry lock && poetry update"
722
842
 
723
843
 
@@ -737,34 +857,35 @@ def upgrade(
737
857
 
738
858
 
739
859
  class GitTag(DryRun):
740
- def __init__(self: Self, message: str, dry: bool) -> None:
860
+ def __init__(self, message: str, dry: bool, no_sync: bool = False) -> None:
741
861
  self.message = message
862
+ self._no_sync = no_sync
742
863
  super().__init__(dry=dry)
743
864
 
744
865
  @staticmethod
745
866
  def has_v_prefix() -> bool:
746
867
  return "v" in capture_cmd_output("git tag")
747
868
 
748
- def should_push(self: Self) -> bool:
869
+ def should_push(self) -> bool:
749
870
  return "git push" in self.git_status
750
871
 
751
- def gen(self: Self) -> str:
752
- _version = get_current_version(verbose=False)
872
+ def gen(self) -> str:
873
+ should_sync, _version = get_current_version(verbose=False, check_version=True)
753
874
  if self.has_v_prefix():
754
875
  # Add `v` at prefix to compare with bumpversion tool
755
876
  _version = "v" + _version
756
877
  cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
757
878
  if self.should_push():
758
879
  cmd += " && git push"
759
- if Project.is_pdm_project():
760
- cmd = "pdm sync --prod && " + cmd
880
+ if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
881
+ cmd = f"{sync} && " + cmd
761
882
  return cmd
762
883
 
763
884
  @cached_property
764
- def git_status(self: Self) -> str:
885
+ def git_status(self) -> str:
765
886
  return capture_cmd_output("git status")
766
887
 
767
- def mark_tag(self: Self) -> bool:
888
+ def mark_tag(self) -> bool:
768
889
  if not re.search(r"working (tree|directory) clean", self.git_status) and (
769
890
  "无文件要提交,干净的工作区" not in self.git_status
770
891
  ):
@@ -773,7 +894,7 @@ class GitTag(DryRun):
773
894
  return False
774
895
  return bool(super().run())
775
896
 
776
- def run(self: Self) -> None:
897
+ def run(self) -> None:
777
898
  if self.mark_tag() and not self.dry:
778
899
  echo("You may want to publish package:\n poetry publish --build")
779
900
 
@@ -781,15 +902,18 @@ class GitTag(DryRun):
781
902
  @cli.command()
782
903
  def tag(
783
904
  message: str = Option("", "-m", "--message"),
905
+ no_sync: bool = Option(
906
+ False, "--no-sync", help="Do not run sync command to update version"
907
+ ),
784
908
  dry: bool = DryOption,
785
909
  ) -> None:
786
910
  """Run shell command: git tag -a <current-version-in-pyproject.toml> -m {message}"""
787
- GitTag(message, dry=dry).run()
911
+ GitTag(message, dry=dry, no_sync=_ensure_bool(no_sync)).run()
788
912
 
789
913
 
790
914
  class LintCode(DryRun):
791
915
  def __init__(
792
- self: Self,
916
+ self,
793
917
  args: list[str] | str | None,
794
918
  check_only: bool = False,
795
919
  _exit: bool = False,
@@ -855,10 +979,21 @@ class LintCode(DryRun):
855
979
  lint_them = " && ".join(
856
980
  "{0}{" + str(i) + "} {1}" for i in range(2, len(tools) + 2)
857
981
  )
982
+ if ruff_exists := cls.check_lint_tool_installed():
983
+ # `ruff <command>` get the same result with `pdm run ruff <command>`
984
+ # While `mypy .`(installed global and env not activated),
985
+ # does not the same as `pdm run mypy .`
986
+ lint_them = " && ".join(
987
+ ("" if tool.startswith("ruff") else "{0}")
988
+ + (
989
+ "{%d} {1}" % i # noqa: UP031
990
+ )
991
+ for i, tool in enumerate(tools, 2)
992
+ )
858
993
  prefix = ""
859
994
  should_run_by_tool = False
860
995
  if is_venv() and Path(sys.argv[0]).parent != Path.home().joinpath(".local/bin"):
861
- if not cls.check_lint_tool_installed():
996
+ if not ruff_exists:
862
997
  should_run_by_tool = True
863
998
  if check_call('python -c "import fast_dev_cli"'):
864
999
  command = 'python -m pip install -U "fast-dev-cli"'
@@ -870,7 +1005,11 @@ class LintCode(DryRun):
870
1005
  if tool == ToolOption.default:
871
1006
  tool = Project.get_manage_tool() or ""
872
1007
  if tool:
873
- prefix = tool + " run "
1008
+ prefix = (
1009
+ str(bin_dir)
1010
+ if tool == "uv" and (bin_dir := Path(".venv/bin/")).exists()
1011
+ else (tool + " run ")
1012
+ )
874
1013
  if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
875
1014
  tools[-1] = "dmypy run"
876
1015
  cmd += lint_them.format(prefix, paths, *tools)
@@ -886,7 +1025,7 @@ class LintCode(DryRun):
886
1025
  cmd += " && " + command
887
1026
  return cmd
888
1027
 
889
- def gen(self: Self) -> str:
1028
+ def gen(self) -> str:
890
1029
  if isinstance(args := self.args, str):
891
1030
  args = args.split()
892
1031
  paths = " ".join(map(str, args)) if args else "."
@@ -975,7 +1114,7 @@ def only_check(
975
1114
 
976
1115
  class Sync(DryRun):
977
1116
  def __init__(
978
- self: Self, filename: str, extras: str, save: bool, dry: bool = False
1117
+ self, filename: str, extras: str, save: bool, dry: bool = False
979
1118
  ) -> None:
980
1119
  self.filename = filename
981
1120
  self.extras = extras
@@ -1124,6 +1263,41 @@ def runserver(
1124
1263
  dev(port, host, dry=dry)
1125
1264
 
1126
1265
 
1266
+ @cli.command(name="exec")
1267
+ def run_by_subprocess(cmd: str, dry: bool = DryOption) -> None:
1268
+ """Run cmd by subprocess, auto set shell=True when cmd contains '|>'"""
1269
+ try:
1270
+ rc = run_and_echo(cmd, verbose=True, dry=_ensure_bool(dry))
1271
+ except FileNotFoundError as e:
1272
+ if e.filename == cmd.split()[0]:
1273
+ echo(f"Command not found: {e.filename}")
1274
+ raise Exit(1) from None
1275
+ raise e
1276
+ else:
1277
+ if rc:
1278
+ raise Exit(rc)
1279
+
1280
+
1281
+ def version_callback(value: bool) -> None:
1282
+ if value:
1283
+ echo("Fast Dev Cli Version: " + typer.style(__version__, bold=True))
1284
+ raise Exit()
1285
+
1286
+
1287
+ @cli.callback()
1288
+ def common(
1289
+ version: bool = Option(
1290
+ None,
1291
+ "--version",
1292
+ "-V",
1293
+ callback=version_callback,
1294
+ is_eager=True,
1295
+ help="Show the version of this tool",
1296
+ ),
1297
+ ) -> None:
1298
+ pass
1299
+
1300
+
1127
1301
  def main() -> None:
1128
1302
  cli()
1129
1303