fast-dev-cli 0.25.1__tar.gz → 0.25.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fast-dev-cli
3
- Version: 0.25.1
3
+ Version: 0.25.3
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.25.3"
@@ -378,6 +378,36 @@ def _ensure_str(value: str | OptionInfo | None) -> str | None:
378
378
  return getattr(value, "default", "")
379
379
 
380
380
 
381
+ def _quote_shell_arg(value: str | Path) -> str:
382
+ text = str(value)
383
+ if not is_windows():
384
+ return shlex.quote(text)
385
+ if text and not re.search(r'[\s"&|<>^()%!]', text):
386
+ return text
387
+
388
+ # Quote for the Windows C runtime while keeping cmd.exe metacharacters inert.
389
+ result = ['"']
390
+ backslashes = 0
391
+ for char in text:
392
+ if char == "\\":
393
+ backslashes += 1
394
+ elif char == '"':
395
+ result.append("\\" * (backslashes * 2 + 1))
396
+ result.append(char)
397
+ backslashes = 0
398
+ else:
399
+ result.append("\\" * backslashes)
400
+ result.append(char)
401
+ backslashes = 0
402
+ result.append("\\" * (backslashes * 2))
403
+ result.append('"')
404
+ return "".join(result)
405
+
406
+
407
+ def _join_shell_args(args: list[str]) -> str:
408
+ return " ".join(_quote_shell_arg(arg) for arg in args)
409
+
410
+
381
411
  class DryRun:
382
412
  def __init__(self, _exit: bool = False, dry: bool = False) -> None:
383
413
  self.dry = _ensure_bool(dry)
@@ -563,6 +593,29 @@ class BumpUp(DryRun):
563
593
  echo(f"Invalid part: {s!r}")
564
594
  raise Exit(1) from e
565
595
 
596
+ @staticmethod
597
+ def parse_new_version(part: str, version: str) -> str:
598
+ version_parts = version.split(".")
599
+ if not version_parts[-1].isdigit():
600
+ try:
601
+ p1, p2, p3 = version_parts[:3]
602
+ p2i = int(p2)
603
+ p1i = int(p1)
604
+ except ValueError:
605
+ ...
606
+ else:
607
+ match part:
608
+ case "patch":
609
+ p3i = int(m.group()) if (m := re.match(r"\d+", p3)) else 0
610
+ if len(version_parts) == 3:
611
+ p3i += 1
612
+ return f"{p1}.{p2}.{p3i}"
613
+ case "minor":
614
+ return f"{p1}.{p2i + 1}.0"
615
+ case "major":
616
+ return f"{p1i + 1}.0.0"
617
+ return ""
618
+
566
619
  def gen(self) -> str:
567
620
  should_sync, _version = get_current_version(check_version=True)
568
621
  filename = self.filename
@@ -575,7 +628,11 @@ class BumpUp(DryRun):
575
628
  part = self.get_part(a)
576
629
  self.part = part
577
630
  parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
578
- cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
631
+ filename_arg = _quote_shell_arg(filename)
632
+ cmd = f'bumpversion {parse} --current-version="{_version}" '
633
+ if new_version := self.parse_new_version(part, _version):
634
+ cmd += f'--new-version="{new_version}" '
635
+ cmd += f"{part} {filename_arg}"
579
636
  if self.commit:
580
637
  if part != "patch":
581
638
  cmd += " --tag"
@@ -641,15 +698,13 @@ def version() -> None:
641
698
  @cli.command(name="bump")
642
699
  def bump_version(
643
700
  part: BumpUp.PartChoices,
701
+ sync: bool = False,
644
702
  commit: bool = Option(
645
703
  False, "--commit", "-c", help="Whether run `git commit` after version changed"
646
704
  ),
647
705
  emoji: bool | None = Option(
648
706
  None, "--emoji", help="Whether add emoji prefix to commit message"
649
707
  ),
650
- no_sync: bool = Option(
651
- False, "--no-sync", help="Do not run sync command to update version"
652
- ),
653
708
  dry: bool = DryOption,
654
709
  ) -> None:
655
710
  """Bump up version string in pyproject.toml"""
@@ -658,7 +713,7 @@ def bump_version(
658
713
  return BumpUp(
659
714
  _ensure_bool(commit),
660
715
  getattr(part, "value", part),
661
- no_sync=_ensure_bool(no_sync),
716
+ no_sync=not _ensure_bool(sync),
662
717
  emoji=emoji,
663
718
  dry=dry,
664
719
  ).run()
@@ -1088,7 +1143,10 @@ class GitTag(DryRun):
1088
1143
  if self.has_v_prefix():
1089
1144
  # Add `v` at prefix to compare with bumpversion tool
1090
1145
  _version = "v" + _version
1091
- cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
1146
+ cmd = (
1147
+ f"git tag -a {_quote_shell_arg(_version)} "
1148
+ f"-m {_quote_shell_arg(self.message)} && git push --tags"
1149
+ )
1092
1150
  if self.should_push():
1093
1151
  cmd += " && git push"
1094
1152
  if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
@@ -1190,7 +1248,7 @@ class LintCode(DryRun):
1190
1248
  @classmethod
1191
1249
  def to_cmd(
1192
1250
  cls: type[Self],
1193
- paths: str = ".",
1251
+ paths: str | list[str] = ".",
1194
1252
  check_only: bool = False,
1195
1253
  bandit: bool = False,
1196
1254
  skip_mypy: bool = False,
@@ -1203,8 +1261,12 @@ class LintCode(DryRun):
1203
1261
  prefer_ty: bool = False,
1204
1262
  ruff_check_fix: bool = True,
1205
1263
  ) -> str:
1206
- if paths != "." and all(i.endswith(".html") for i in paths.split()):
1207
- return f"prettier -w {paths}"
1264
+ path_args = shlex.split(paths) if isinstance(paths, str) else paths
1265
+ if not path_args:
1266
+ path_args = ["."]
1267
+ quoted_paths = _join_shell_args(path_args)
1268
+ if path_args != ["."] and all(i.endswith(".html") for i in path_args):
1269
+ return f"prettier -w {quoted_paths}"
1208
1270
  ruff_rules = ["I", "B"]
1209
1271
  if ruff_check_sim and not load_bool("FASTDEVCLI_NO_SIM"):
1210
1272
  ruff_rules.append("SIM")
@@ -1294,7 +1356,7 @@ class LintCode(DryRun):
1294
1356
  prefix += "--no-sync "
1295
1357
  elif Path(bin_dir := ".venv/bin/").exists():
1296
1358
  prefix = bin_dir
1297
- if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
1359
+ if cls.prefer_dmypy(quoted_paths, tools, use_dmypy=use_dmypy):
1298
1360
  tools[-1] = "dmypy run"
1299
1361
  cmd = " && ".join(
1300
1362
  (
@@ -1308,7 +1370,7 @@ class LintCode(DryRun):
1308
1370
  )
1309
1371
  else prefix + tool
1310
1372
  )
1311
- + f" {paths}"
1373
+ + f" {quoted_paths}"
1312
1374
  for tool in tools
1313
1375
  )
1314
1376
  if bandit or load_bool("FASTDEVCLI_BANDIT"):
@@ -1317,36 +1379,35 @@ class LintCode(DryRun):
1317
1379
  toml_text = Project.load_toml_text()
1318
1380
  if "[tool.bandit" in toml_text:
1319
1381
  command += " -c pyproject.toml"
1320
- if paths == "." and " -c " not in command:
1321
- paths = cls.get_package_name()
1322
- command += f" -r {paths}"
1382
+ if quoted_paths == "." and " -c " not in command:
1383
+ quoted_paths = _quote_shell_arg(cls.get_package_name())
1384
+ command += f" -r {quoted_paths}"
1323
1385
  cmd += " && " + command
1324
1386
  return cmd
1325
1387
 
1326
1388
  def gen(self) -> str:
1327
- paths = "."
1389
+ paths = ["."]
1328
1390
  if args := self.args:
1329
- ps = args.split() if isinstance(args, str) else [str(i) for i in args]
1391
+ ps = shlex.split(args) if isinstance(args, str) else [str(i) for i in args]
1330
1392
  if len(ps) == 1:
1331
- paths = ps[0]
1393
+ path = ps[0]
1332
1394
  if (
1333
- paths != "."
1395
+ path != "."
1334
1396
  # `Path("a.").suffix` got "." in py3.14 and got "" with py<3.14
1335
- and (p := Path(paths)).suffix in ("", ".")
1397
+ and (p := Path(path)).suffix in ("", ".")
1336
1398
  and not p.exists()
1337
1399
  ):
1338
1400
  # e.g.:
1339
1401
  # stem -> stem.py
1340
1402
  # me. -> me.py
1341
- if paths.endswith("."):
1342
- p = p.with_name(paths[:-1])
1403
+ if path.endswith("."):
1404
+ p = p.with_name(path[:-1])
1343
1405
  for suffix in (".py", ".html"):
1344
1406
  p = p.with_suffix(suffix)
1345
1407
  if p.exists():
1346
- paths = p.name
1408
+ ps[0] = p.name
1347
1409
  break
1348
- else:
1349
- paths = " ".join(ps)
1410
+ paths = ps
1350
1411
  return self.to_cmd(
1351
1412
  paths,
1352
1413
  self.check_only,
@@ -1517,10 +1578,11 @@ class Sync(DryRun):
1517
1578
  def gen(self) -> str:
1518
1579
  extras, save = self.extras, self._save
1519
1580
  should_remove = not Path.cwd().joinpath(self.filename).exists()
1581
+ filename = _quote_shell_arg(self.filename)
1520
1582
  if not (tool := Project.get_manage_tool()):
1521
1583
  if should_remove or not is_venv():
1522
1584
  raise EnvError("There project is not managed by uv/pdm/poetry!")
1523
- return f"python -m pip install -r {self.filename}"
1585
+ return f"python -m pip install -r {filename}"
1524
1586
  prefix = ""
1525
1587
  if not is_venv():
1526
1588
  prefix = f"{tool} run " + "--no-sync " * (tool == "uv")
@@ -1533,7 +1595,7 @@ class Sync(DryRun):
1533
1595
  if not UpgradeDependencies.should_with_dev():
1534
1596
  export_cmd = export_cmd.replace(" --with=dev", "")
1535
1597
  if extras and isinstance(extras, str | list):
1536
- export_cmd += f" --{extras=}".replace("'", '"')
1598
+ export_cmd += f" --extras={_quote_shell_arg(str(extras))}"
1537
1599
  elif check_call(prefix + "python -m pip --version"):
1538
1600
  ensure_pip = ""
1539
1601
  elif check_call(prefix + "python -m pip --version"):
@@ -1543,7 +1605,7 @@ class Sync(DryRun):
1543
1605
  )
1544
1606
  if should_remove and not save:
1545
1607
  install_cmd += " && rm -f {0}"
1546
- return install_cmd.format(self.filename, prefix, export_cmd)
1608
+ return install_cmd.format(filename, prefix, export_cmd)
1547
1609
 
1548
1610
 
1549
1611
  @cli.command()
@@ -1573,11 +1635,11 @@ def test(dry: bool, ignore_script: bool = False) -> None:
1573
1635
  if not _ensure_bool(ignore_script) and (
1574
1636
  test_script := _should_run_test_script(script_dir)
1575
1637
  ):
1576
- cmd = test_script.relative_to(root).as_posix()
1638
+ cmd = _quote_shell_arg(test_script.relative_to(root).as_posix())
1577
1639
  if test_script.suffix == ".py":
1578
1640
  cmd = "python " + cmd
1579
1641
  if cwd != root:
1580
- cmd = f"cd {root} && " + cmd
1642
+ cmd = f"cd {_quote_shell_arg(root)} && " + cmd
1581
1643
  else:
1582
1644
  cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
1583
1645
  if not is_venv() or not check_call("coverage --version"):
@@ -1645,6 +1707,7 @@ def should_use_just() -> bool:
1645
1707
  return _prefer_just_dev(f)
1646
1708
  if d.joinpath("pyproject.toml").exists():
1647
1709
  break
1710
+ d = d.parent
1648
1711
  return False
1649
1712
 
1650
1713
 
@@ -1654,10 +1717,22 @@ def _prefer_just_dev(f: Path) -> bool:
1654
1717
  dev_recipe = "dev *args:"
1655
1718
  re_import = re.compile(r"import[?]? ")
1656
1719
  has_import = False
1720
+ total = len(lines)
1657
1721
  for i, line in enumerate(lines):
1658
1722
  if line.startswith(dev_recipe):
1659
1723
  # Avoid cycle callback
1660
- return "fast dev" not in lines[i + 1]
1724
+ command_lines = []
1725
+ for j in range(i + 1, total):
1726
+ try:
1727
+ s = lines[j]
1728
+ except IndexError:
1729
+ break
1730
+ if not s.startswith(" "):
1731
+ break
1732
+ command_lines.append(s)
1733
+ if not command_lines: # Invalid justfile
1734
+ return False
1735
+ return all("fast dev" not in i for i in command_lines)
1661
1736
  elif not has_import and re_import.match(line):
1662
1737
  has_import = True
1663
1738
  if has_import:
@@ -1669,6 +1744,17 @@ def _prefer_just_dev(f: Path) -> bool:
1669
1744
  return False
1670
1745
 
1671
1746
 
1747
+ def _load_fastapi_entrypoint() -> str:
1748
+ with contextlib.suppress(FileNotFoundError, KeyError):
1749
+ try:
1750
+ toml_text = Project.load_toml_text()
1751
+ except EnvError:
1752
+ return ""
1753
+ doc = tomllib.loads(toml_text)
1754
+ return doc["tool"]["fastapi"]["entrypoint"]
1755
+ return ""
1756
+
1757
+
1672
1758
  def _parse_serve_file(
1673
1759
  uvicorn: bool | None, filename: str, cmd: str, args: list[str]
1674
1760
  ) -> str:
@@ -1681,20 +1767,23 @@ def _parse_serve_file(
1681
1767
  args.append(f"--host={h}")
1682
1768
  args.append(f"--port={p}")
1683
1769
  if uvicorn:
1684
- p = Path("main.py")
1685
- if p.exists():
1686
- cmd += " main:app"
1687
- elif Path("app", p.name).exists():
1688
- cmd += " app.main:app"
1689
- elif Path("app.py").exists():
1690
- cmd += " app:app"
1770
+ if entrypoint := _load_fastapi_entrypoint():
1771
+ cmd += " " + entrypoint
1772
+ else:
1773
+ p = Path("main.py")
1774
+ if p.exists():
1775
+ cmd += " main:app"
1776
+ elif Path("app", p.name).exists():
1777
+ cmd += " app.main:app"
1778
+ elif Path("app.py").exists():
1779
+ cmd += " app:app"
1691
1780
  return cmd
1692
1781
  if uvicorn and ((filepath := Path(filename)).is_file() or filepath.suffix == ".py"):
1693
1782
  filename = filepath.stem + ":app"
1694
1783
  parent_names = [j for i in filepath.parents if (j := i.name)]
1695
1784
  if parent_names:
1696
1785
  filename = ".".join([*parent_names[::-1], filename])
1697
- cmd += " " + filename
1786
+ cmd += " " + _quote_shell_arg(filename)
1698
1787
  return cmd
1699
1788
 
1700
1789
 
@@ -1722,6 +1811,8 @@ def _runserver(
1722
1811
  if port != 8000:
1723
1812
  args.append(f"--port={port}")
1724
1813
  no_port_yet = False
1814
+ elif uvicorn and (entrypoint := _load_fastapi_entrypoint()):
1815
+ cmd += " " + entrypoint
1725
1816
  if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
1726
1817
  args.append(f"--port={port}")
1727
1818
  if shutil.which("pdm") is not None:
@@ -1746,7 +1837,7 @@ def dev(
1746
1837
  else:
1747
1838
  cmd, args = _runserver(uvicorn, host, port, file)
1748
1839
  if args:
1749
- cmd += " " + " ".join(args)
1840
+ cmd += " " + _join_shell_args(args)
1750
1841
  exit_if_run_failed(cmd, dry=dry)
1751
1842
 
1752
1843
 
@@ -1835,9 +1926,13 @@ class MakeDeps(DryRun):
1835
1926
  if opt not in cmd:
1836
1927
  cmd += opt
1837
1928
  if self._no_extra:
1838
- cmd += " " + " ".join(f"--no-extra {i}" for i in self._no_extra)
1929
+ cmd += " " + " ".join(
1930
+ f"--no-extra {_quote_shell_arg(i)}" for i in self._no_extra
1931
+ )
1839
1932
  if self._no_group:
1840
- cmd += " " + " ".join(f"--no-group {i}" for i in self._no_group)
1933
+ cmd += " " + " ".join(
1934
+ f"--no-group {_quote_shell_arg(i)}" for i in self._no_group
1935
+ )
1841
1936
  if self._frozen:
1842
1937
  cmd += " --frozen"
1843
1938
  if opts := os.getenv("FASTDEVCLI_DEPS_OPTS"):
@@ -1873,7 +1968,7 @@ class MakeDeps(DryRun):
1873
1968
  elif self._tool == "uv":
1874
1969
  uv_sync = "uv sync"
1875
1970
  if project := self.get_package_name():
1876
- uv_sync += f" --reinstall-package={project}"
1971
+ uv_sync += " " + _quote_shell_arg(f"--reinstall-package={project}")
1877
1972
  uv_sync += " --inexact" * self._inexact + " --active" * self._active
1878
1973
  return uv_sync + (
1879
1974
  " --no-dev" if self._prod else " --all-extras --all-groups"
@@ -29,7 +29,7 @@ dependencies = [
29
29
  "typer>=0.24.0,<1",
30
30
  "tomli >=2.0.1,<3; python_version < '3.11'",
31
31
  ]
32
- version = "0.25.1"
32
+ version = "0.25.3"
33
33
 
34
34
  [project.urls]
35
35
  Homepage = "https://github.com/waketzheng/fast-dev-cli"
@@ -1 +0,0 @@
1
- __version__ = "0.25.1"
File without changes
File without changes