fast-dev-cli 0.25.1__tar.gz → 0.25.2__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.2
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.2"
@@ -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)
@@ -575,7 +605,10 @@ class BumpUp(DryRun):
575
605
  part = self.get_part(a)
576
606
  self.part = part
577
607
  parse = r'--parse "(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'
578
- cmd = f'bumpversion {parse} --current-version="{_version}" {part} {filename}'
608
+ filename_arg = _quote_shell_arg(filename)
609
+ cmd = (
610
+ f'bumpversion {parse} --current-version="{_version}" {part} {filename_arg}'
611
+ )
579
612
  if self.commit:
580
613
  if part != "patch":
581
614
  cmd += " --tag"
@@ -1088,7 +1121,10 @@ class GitTag(DryRun):
1088
1121
  if self.has_v_prefix():
1089
1122
  # Add `v` at prefix to compare with bumpversion tool
1090
1123
  _version = "v" + _version
1091
- cmd = f"git tag -a {_version} -m {self.message!r} && git push --tags"
1124
+ cmd = (
1125
+ f"git tag -a {_quote_shell_arg(_version)} "
1126
+ f"-m {_quote_shell_arg(self.message)} && git push --tags"
1127
+ )
1092
1128
  if self.should_push():
1093
1129
  cmd += " && git push"
1094
1130
  if should_sync and not self._no_sync and (sync := Project.get_sync_command()):
@@ -1190,7 +1226,7 @@ class LintCode(DryRun):
1190
1226
  @classmethod
1191
1227
  def to_cmd(
1192
1228
  cls: type[Self],
1193
- paths: str = ".",
1229
+ paths: str | list[str] = ".",
1194
1230
  check_only: bool = False,
1195
1231
  bandit: bool = False,
1196
1232
  skip_mypy: bool = False,
@@ -1203,8 +1239,12 @@ class LintCode(DryRun):
1203
1239
  prefer_ty: bool = False,
1204
1240
  ruff_check_fix: bool = True,
1205
1241
  ) -> str:
1206
- if paths != "." and all(i.endswith(".html") for i in paths.split()):
1207
- return f"prettier -w {paths}"
1242
+ path_args = shlex.split(paths) if isinstance(paths, str) else paths
1243
+ if not path_args:
1244
+ path_args = ["."]
1245
+ quoted_paths = _join_shell_args(path_args)
1246
+ if path_args != ["."] and all(i.endswith(".html") for i in path_args):
1247
+ return f"prettier -w {quoted_paths}"
1208
1248
  ruff_rules = ["I", "B"]
1209
1249
  if ruff_check_sim and not load_bool("FASTDEVCLI_NO_SIM"):
1210
1250
  ruff_rules.append("SIM")
@@ -1294,7 +1334,7 @@ class LintCode(DryRun):
1294
1334
  prefix += "--no-sync "
1295
1335
  elif Path(bin_dir := ".venv/bin/").exists():
1296
1336
  prefix = bin_dir
1297
- if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
1337
+ if cls.prefer_dmypy(quoted_paths, tools, use_dmypy=use_dmypy):
1298
1338
  tools[-1] = "dmypy run"
1299
1339
  cmd = " && ".join(
1300
1340
  (
@@ -1308,7 +1348,7 @@ class LintCode(DryRun):
1308
1348
  )
1309
1349
  else prefix + tool
1310
1350
  )
1311
- + f" {paths}"
1351
+ + f" {quoted_paths}"
1312
1352
  for tool in tools
1313
1353
  )
1314
1354
  if bandit or load_bool("FASTDEVCLI_BANDIT"):
@@ -1317,36 +1357,35 @@ class LintCode(DryRun):
1317
1357
  toml_text = Project.load_toml_text()
1318
1358
  if "[tool.bandit" in toml_text:
1319
1359
  command += " -c pyproject.toml"
1320
- if paths == "." and " -c " not in command:
1321
- paths = cls.get_package_name()
1322
- command += f" -r {paths}"
1360
+ if quoted_paths == "." and " -c " not in command:
1361
+ quoted_paths = _quote_shell_arg(cls.get_package_name())
1362
+ command += f" -r {quoted_paths}"
1323
1363
  cmd += " && " + command
1324
1364
  return cmd
1325
1365
 
1326
1366
  def gen(self) -> str:
1327
- paths = "."
1367
+ paths = ["."]
1328
1368
  if args := self.args:
1329
- ps = args.split() if isinstance(args, str) else [str(i) for i in args]
1369
+ ps = shlex.split(args) if isinstance(args, str) else [str(i) for i in args]
1330
1370
  if len(ps) == 1:
1331
- paths = ps[0]
1371
+ path = ps[0]
1332
1372
  if (
1333
- paths != "."
1373
+ path != "."
1334
1374
  # `Path("a.").suffix` got "." in py3.14 and got "" with py<3.14
1335
- and (p := Path(paths)).suffix in ("", ".")
1375
+ and (p := Path(path)).suffix in ("", ".")
1336
1376
  and not p.exists()
1337
1377
  ):
1338
1378
  # e.g.:
1339
1379
  # stem -> stem.py
1340
1380
  # me. -> me.py
1341
- if paths.endswith("."):
1342
- p = p.with_name(paths[:-1])
1381
+ if path.endswith("."):
1382
+ p = p.with_name(path[:-1])
1343
1383
  for suffix in (".py", ".html"):
1344
1384
  p = p.with_suffix(suffix)
1345
1385
  if p.exists():
1346
- paths = p.name
1386
+ ps[0] = p.name
1347
1387
  break
1348
- else:
1349
- paths = " ".join(ps)
1388
+ paths = ps
1350
1389
  return self.to_cmd(
1351
1390
  paths,
1352
1391
  self.check_only,
@@ -1517,10 +1556,11 @@ class Sync(DryRun):
1517
1556
  def gen(self) -> str:
1518
1557
  extras, save = self.extras, self._save
1519
1558
  should_remove = not Path.cwd().joinpath(self.filename).exists()
1559
+ filename = _quote_shell_arg(self.filename)
1520
1560
  if not (tool := Project.get_manage_tool()):
1521
1561
  if should_remove or not is_venv():
1522
1562
  raise EnvError("There project is not managed by uv/pdm/poetry!")
1523
- return f"python -m pip install -r {self.filename}"
1563
+ return f"python -m pip install -r {filename}"
1524
1564
  prefix = ""
1525
1565
  if not is_venv():
1526
1566
  prefix = f"{tool} run " + "--no-sync " * (tool == "uv")
@@ -1533,7 +1573,7 @@ class Sync(DryRun):
1533
1573
  if not UpgradeDependencies.should_with_dev():
1534
1574
  export_cmd = export_cmd.replace(" --with=dev", "")
1535
1575
  if extras and isinstance(extras, str | list):
1536
- export_cmd += f" --{extras=}".replace("'", '"')
1576
+ export_cmd += f" --extras={_quote_shell_arg(str(extras))}"
1537
1577
  elif check_call(prefix + "python -m pip --version"):
1538
1578
  ensure_pip = ""
1539
1579
  elif check_call(prefix + "python -m pip --version"):
@@ -1543,7 +1583,7 @@ class Sync(DryRun):
1543
1583
  )
1544
1584
  if should_remove and not save:
1545
1585
  install_cmd += " && rm -f {0}"
1546
- return install_cmd.format(self.filename, prefix, export_cmd)
1586
+ return install_cmd.format(filename, prefix, export_cmd)
1547
1587
 
1548
1588
 
1549
1589
  @cli.command()
@@ -1573,11 +1613,11 @@ def test(dry: bool, ignore_script: bool = False) -> None:
1573
1613
  if not _ensure_bool(ignore_script) and (
1574
1614
  test_script := _should_run_test_script(script_dir)
1575
1615
  ):
1576
- cmd = test_script.relative_to(root).as_posix()
1616
+ cmd = _quote_shell_arg(test_script.relative_to(root).as_posix())
1577
1617
  if test_script.suffix == ".py":
1578
1618
  cmd = "python " + cmd
1579
1619
  if cwd != root:
1580
- cmd = f"cd {root} && " + cmd
1620
+ cmd = f"cd {_quote_shell_arg(root)} && " + cmd
1581
1621
  else:
1582
1622
  cmd = 'coverage run -m pytest -s && coverage report --omit="tests/*" -m'
1583
1623
  if not is_venv() or not check_call("coverage --version"):
@@ -1645,6 +1685,7 @@ def should_use_just() -> bool:
1645
1685
  return _prefer_just_dev(f)
1646
1686
  if d.joinpath("pyproject.toml").exists():
1647
1687
  break
1688
+ d = d.parent
1648
1689
  return False
1649
1690
 
1650
1691
 
@@ -1654,10 +1695,22 @@ def _prefer_just_dev(f: Path) -> bool:
1654
1695
  dev_recipe = "dev *args:"
1655
1696
  re_import = re.compile(r"import[?]? ")
1656
1697
  has_import = False
1698
+ total = len(lines)
1657
1699
  for i, line in enumerate(lines):
1658
1700
  if line.startswith(dev_recipe):
1659
1701
  # Avoid cycle callback
1660
- return "fast dev" not in lines[i + 1]
1702
+ command_lines = []
1703
+ for j in range(i + 1, total):
1704
+ try:
1705
+ s = lines[j]
1706
+ except IndexError:
1707
+ break
1708
+ if not s.startswith(" "):
1709
+ break
1710
+ command_lines.append(s)
1711
+ if not command_lines: # Invalid justfile
1712
+ return False
1713
+ return all("fast dev" not in i for i in command_lines)
1661
1714
  elif not has_import and re_import.match(line):
1662
1715
  has_import = True
1663
1716
  if has_import:
@@ -1669,6 +1722,17 @@ def _prefer_just_dev(f: Path) -> bool:
1669
1722
  return False
1670
1723
 
1671
1724
 
1725
+ def _load_fastapi_entrypoint() -> str:
1726
+ with contextlib.suppress(FileNotFoundError, KeyError):
1727
+ try:
1728
+ toml_text = Project.load_toml_text()
1729
+ except EnvError:
1730
+ return ""
1731
+ doc = tomllib.loads(toml_text)
1732
+ return doc["tool"]["fastapi"]["entrypoint"]
1733
+ return ""
1734
+
1735
+
1672
1736
  def _parse_serve_file(
1673
1737
  uvicorn: bool | None, filename: str, cmd: str, args: list[str]
1674
1738
  ) -> str:
@@ -1681,20 +1745,23 @@ def _parse_serve_file(
1681
1745
  args.append(f"--host={h}")
1682
1746
  args.append(f"--port={p}")
1683
1747
  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"
1748
+ if entrypoint := _load_fastapi_entrypoint():
1749
+ cmd += " " + entrypoint
1750
+ else:
1751
+ p = Path("main.py")
1752
+ if p.exists():
1753
+ cmd += " main:app"
1754
+ elif Path("app", p.name).exists():
1755
+ cmd += " app.main:app"
1756
+ elif Path("app.py").exists():
1757
+ cmd += " app:app"
1691
1758
  return cmd
1692
1759
  if uvicorn and ((filepath := Path(filename)).is_file() or filepath.suffix == ".py"):
1693
1760
  filename = filepath.stem + ":app"
1694
1761
  parent_names = [j for i in filepath.parents if (j := i.name)]
1695
1762
  if parent_names:
1696
1763
  filename = ".".join([*parent_names[::-1], filename])
1697
- cmd += " " + filename
1764
+ cmd += " " + _quote_shell_arg(filename)
1698
1765
  return cmd
1699
1766
 
1700
1767
 
@@ -1722,6 +1789,8 @@ def _runserver(
1722
1789
  if port != 8000:
1723
1790
  args.append(f"--port={port}")
1724
1791
  no_port_yet = False
1792
+ elif uvicorn and (entrypoint := _load_fastapi_entrypoint()):
1793
+ cmd += " " + entrypoint
1725
1794
  if no_port_yet and (port := getattr(port, "default", port)) and str(port) != "8000":
1726
1795
  args.append(f"--port={port}")
1727
1796
  if shutil.which("pdm") is not None:
@@ -1746,7 +1815,7 @@ def dev(
1746
1815
  else:
1747
1816
  cmd, args = _runserver(uvicorn, host, port, file)
1748
1817
  if args:
1749
- cmd += " " + " ".join(args)
1818
+ cmd += " " + _join_shell_args(args)
1750
1819
  exit_if_run_failed(cmd, dry=dry)
1751
1820
 
1752
1821
 
@@ -1835,9 +1904,13 @@ class MakeDeps(DryRun):
1835
1904
  if opt not in cmd:
1836
1905
  cmd += opt
1837
1906
  if self._no_extra:
1838
- cmd += " " + " ".join(f"--no-extra {i}" for i in self._no_extra)
1907
+ cmd += " " + " ".join(
1908
+ f"--no-extra {_quote_shell_arg(i)}" for i in self._no_extra
1909
+ )
1839
1910
  if self._no_group:
1840
- cmd += " " + " ".join(f"--no-group {i}" for i in self._no_group)
1911
+ cmd += " " + " ".join(
1912
+ f"--no-group {_quote_shell_arg(i)}" for i in self._no_group
1913
+ )
1841
1914
  if self._frozen:
1842
1915
  cmd += " --frozen"
1843
1916
  if opts := os.getenv("FASTDEVCLI_DEPS_OPTS"):
@@ -1873,7 +1946,7 @@ class MakeDeps(DryRun):
1873
1946
  elif self._tool == "uv":
1874
1947
  uv_sync = "uv sync"
1875
1948
  if project := self.get_package_name():
1876
- uv_sync += f" --reinstall-package={project}"
1949
+ uv_sync += " " + _quote_shell_arg(f"--reinstall-package={project}")
1877
1950
  uv_sync += " --inexact" * self._inexact + " --active" * self._active
1878
1951
  return uv_sync + (
1879
1952
  " --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.2"
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