fast-dev-cli 0.14.2__tar.gz → 0.15.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 (27) hide show
  1. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/PKG-INFO +2 -2
  2. fast_dev_cli-0.15.1/fast_dev_cli/__init__.py +1 -0
  3. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/fast_dev_cli/cli.py +152 -65
  4. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/pyproject.toml +3 -2
  5. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/conftest.py +8 -3
  6. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_bump.py +93 -0
  7. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_functions.py +4 -2
  8. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_lint.py +53 -5
  9. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_upgrade.py +36 -0
  10. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/utils.py +12 -15
  11. fast_dev_cli-0.14.2/fast_dev_cli/__init__.py +0 -1
  12. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/LICENSE +0 -0
  13. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/README.md +0 -0
  14. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/fast_dev_cli/__main__.py +0 -0
  15. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/fast_dev_cli/py.typed +0 -0
  16. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/pdm_build.py +0 -0
  17. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/scripts/check.py +0 -0
  18. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/scripts/format.py +0 -0
  19. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/scripts/test.py +0 -0
  20. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/__init__.py +0 -0
  21. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_fast_test.py +0 -0
  22. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_poetry_version_plugin.py +0 -0
  23. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_runserver.py +0 -0
  24. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_sync.py +0 -0
  25. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_tag.py +0 -0
  26. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_upload.py +0 -0
  27. {fast_dev_cli-0.14.2 → fast_dev_cli-0.15.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fast-dev-cli
3
- Version: 0.14.2
3
+ Version: 0.15.1
4
4
  Summary: Python project development tool.
5
5
  Author-Email: Waket Zheng <waketzheng@gmail.com>>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -23,12 +23,12 @@ Project-URL: Homepage, https://github.com/waketzheng/fast-dev-cli
23
23
  Requires-Python: >=3.9
24
24
  Requires-Dist: typer<1,>=0.12.3
25
25
  Requires-Dist: emoji<3,>=2.12.1
26
+ Requires-Dist: packaging>=20.5
26
27
  Requires-Dist: tomli<3,>=2.0.1; python_version < "3.11"
27
28
  Requires-Dist: coverage<8,>=7.5.1
28
29
  Requires-Dist: mypy<2,>=1.15.0
29
30
  Requires-Dist: bumpversion2<2,>=1.4.3
30
31
  Requires-Dist: pytest<9,>=8.2.0
31
- Requires-Dist: packaging>=20.5
32
32
  Provides-Extra: include-optional-dependencies
33
33
  Requires-Dist: all; extra == "include-optional-dependencies"
34
34
  Description-Content-Type: text/markdown
@@ -0,0 +1 @@
1
+ __version__ = "0.15.1"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import importlib.metadata as importlib_metadata
4
5
  import os
5
6
  import re
@@ -8,7 +9,14 @@ import subprocess # nosec:B404
8
9
  import sys
9
10
  from functools import cached_property
10
11
  from pathlib import Path
11
- from typing import Literal, Optional, get_args # Optional is required by typers
12
+ from typing import (
13
+ Any,
14
+ Literal,
15
+ # Optional is required by Option generated by typer
16
+ Optional,
17
+ cast,
18
+ get_args,
19
+ )
12
20
 
13
21
  import emoji
14
22
  import typer
@@ -41,6 +49,9 @@ cli = typer.Typer()
41
49
  DryOption = Option(False, "--dry", help="Only print, not really run shell command")
42
50
  TOML_FILE = "pyproject.toml"
43
51
  ToolName = Literal["poetry", "pdm", "uv"]
52
+ ToolOption = Option(
53
+ "auto", "--tool", help="Explicit declare manage tool (default to auto detect)"
54
+ )
44
55
 
45
56
 
46
57
  class ShellCommandError(Exception): ...
@@ -53,7 +64,7 @@ def poetry_module_name(name: str) -> str:
53
64
  return canonicalize_name(name).replace("-", "_").replace(" ", "_")
54
65
 
55
66
 
56
- def load_bool(name: str, default=False) -> bool:
67
+ def load_bool(name: str, default: bool = False) -> bool:
57
68
  if not (v := os.getenv(name)):
58
69
  return default
59
70
  if (lower := v.lower()) in ("0", "false", "f", "off", "no", "n"):
@@ -71,13 +82,15 @@ def is_venv() -> bool:
71
82
  )
72
83
 
73
84
 
74
- def _run_shell(cmd: list[str] | str, **kw) -> subprocess.CompletedProcess:
85
+ def _run_shell(cmd: list[str] | str, **kw: Any) -> subprocess.CompletedProcess[str]:
75
86
  if isinstance(cmd, str):
76
87
  kw.setdefault("shell", True)
77
88
  return subprocess.run(cmd, **kw) # nosec:B603
78
89
 
79
90
 
80
- def run_and_echo(cmd: str, *, dry=False, verbose=True, **kw) -> int:
91
+ def run_and_echo(
92
+ cmd: str, *, dry: bool = False, verbose: bool = True, **kw: Any
93
+ ) -> int:
81
94
  """Run shell command with subprocess and print it"""
82
95
  if verbose:
83
96
  echo(f"--> {cmd}")
@@ -91,7 +104,9 @@ def check_call(cmd: str) -> bool:
91
104
  return r.returncode == 0
92
105
 
93
106
 
94
- def capture_cmd_output(command: list[str] | str, *, raises=False, **kw) -> str:
107
+ def capture_cmd_output(
108
+ command: list[str] | str, *, raises: bool = False, **kw: Any
109
+ ) -> str:
95
110
  if isinstance(command, str) and not kw.get("shell"):
96
111
  command = shlex.split(command)
97
112
  r = _run_shell(command, capture_output=True, encoding="utf-8", **kw)
@@ -100,12 +115,12 @@ def capture_cmd_output(command: list[str] | str, *, raises=False, **kw) -> str:
100
115
  return r.stdout.strip() or r.stderr
101
116
 
102
117
 
103
- def _parse_version(line: str, pattern: re.Pattern) -> str:
118
+ def _parse_version(line: str, pattern: re.Pattern[str]) -> str:
104
119
  return pattern.sub("", line).split("#")[0].strip(" '\"")
105
120
 
106
121
 
107
122
  def read_version_from_file(
108
- package_name: str, work_dir=None, toml_text: str | None = None
123
+ package_name: str, work_dir: Path | None = None, toml_text: str | None = None
109
124
  ) -> str:
110
125
  if toml_text is None:
111
126
  toml_text = Project.load_toml_text()
@@ -113,10 +128,10 @@ def read_version_from_file(
113
128
  invalid = ("0", "0.0.0")
114
129
  for line in toml_text.splitlines():
115
130
  if pattern.match(line):
116
- version = _parse_version(line, pattern)
117
- if version.startswith("{") or version in invalid:
131
+ lib_version = _parse_version(line, pattern)
132
+ if lib_version.startswith("{") or lib_version in invalid:
118
133
  break
119
- return version
134
+ return lib_version
120
135
  if work_dir is None:
121
136
  work_dir = Project.get_work_dir()
122
137
  package_dir = work_dir / package_name
@@ -136,7 +151,9 @@ def read_version_from_file(
136
151
 
137
152
 
138
153
  def get_current_version(
139
- verbose=False, is_poetry: bool | None = None, package_name: str | None = None
154
+ verbose: bool = False,
155
+ is_poetry: bool | None = None,
156
+ package_name: str | None = None,
140
157
  ) -> str:
141
158
  if is_poetry is None:
142
159
  is_poetry = Project.manage_by_poetry()
@@ -164,9 +181,19 @@ def _ensure_bool(value: bool | OptionInfo) -> bool:
164
181
  return value
165
182
 
166
183
 
184
+ def _ensure_str(value: str | OptionInfo) -> str:
185
+ if not isinstance(value, str):
186
+ value = getattr(value, "default", "")
187
+ return value
188
+
189
+
167
190
  def exit_if_run_failed(
168
- cmd: str, env=None, _exit=False, dry=False, **kw
169
- ) -> subprocess.CompletedProcess:
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]:
170
197
  run_and_echo(cmd, dry=True)
171
198
  if _ensure_bool(dry):
172
199
  return subprocess.CompletedProcess("", 0)
@@ -181,7 +208,7 @@ def exit_if_run_failed(
181
208
 
182
209
 
183
210
  class DryRun:
184
- def __init__(self: Self, _exit=False, dry=False) -> None:
211
+ def __init__(self: Self, _exit: bool = False, dry: bool = False) -> None:
185
212
  self.dry = dry
186
213
  self._exit = _exit
187
214
 
@@ -199,7 +226,11 @@ class BumpUp(DryRun):
199
226
  major = "major"
200
227
 
201
228
  def __init__(
202
- self: Self, commit: bool, part: str, filename: str | None = None, dry=False
229
+ self: Self,
230
+ commit: bool,
231
+ part: str,
232
+ filename: str | None = None,
233
+ dry: bool = False,
203
234
  ) -> None:
204
235
  self.commit = commit
205
236
  self.part = part
@@ -209,9 +240,9 @@ class BumpUp(DryRun):
209
240
  super().__init__(dry=dry)
210
241
 
211
242
  @staticmethod
212
- def get_last_commit_message() -> str:
243
+ def get_last_commit_message(raises: bool = False) -> str:
213
244
  cmd = 'git show --pretty=format:"%s" -s HEAD'
214
- return capture_cmd_output(cmd)
245
+ return capture_cmd_output(cmd, raises=raises)
215
246
 
216
247
  @classmethod
217
248
  def should_add_emoji(cls) -> bool:
@@ -219,9 +250,12 @@ class BumpUp(DryRun):
219
250
  If last commit message is startswith emoji,
220
251
  add a ⬆️ flag at the prefix of bump up commit message.
221
252
  """
222
- if out := cls.get_last_commit_message():
223
- return emoji.is_emoji(out[0])
224
- return False
253
+ try:
254
+ first_char = cls.get_last_commit_message(raises=True)[0]
255
+ except (IndexError, ShellCommandError):
256
+ return False
257
+ else:
258
+ return emoji.is_emoji(first_char)
225
259
 
226
260
  @staticmethod
227
261
  def parse_filename() -> str:
@@ -243,6 +277,16 @@ class BumpUp(DryRun):
243
277
  version_value = context["tool"]["poetry"]["version"]
244
278
  except KeyError:
245
279
  if not Project.manage_by_poetry():
280
+ for tool in ("pdm", "hatch"):
281
+ with contextlib.suppress(KeyError):
282
+ version_path = context["tool"][tool]["version"]["path"]
283
+ if (
284
+ Path(version_path).exists()
285
+ or Project.get_work_dir()
286
+ .joinpath(version_path)
287
+ .exists()
288
+ ):
289
+ return version_path
246
290
  # version = { source = "file", path = "fast_dev_cli/__init__.py" }
247
291
  v_key = "version = "
248
292
  p_key = 'path = "'
@@ -379,7 +423,9 @@ class Project:
379
423
  return 'build-backend = "poetry' in text
380
424
 
381
425
  @staticmethod
382
- def work_dir(name: str, parent: Path, depth: int, be_file=False) -> Path | None:
426
+ def work_dir(
427
+ name: str, parent: Path, depth: int, be_file: bool = False
428
+ ) -> Path | None:
383
429
  for _ in range(depth):
384
430
  if (f := parent.joinpath(name)).exists():
385
431
  if be_file:
@@ -391,10 +437,10 @@ class Project:
391
437
  @classmethod
392
438
  def get_work_dir(
393
439
  cls: type[Self],
394
- name=TOML_FILE,
440
+ name: str = TOML_FILE,
395
441
  cwd: Path | None = None,
396
- allow_cwd=False,
397
- be_file=False,
442
+ allow_cwd: bool = False,
443
+ be_file: bool = False,
398
444
  ) -> Path:
399
445
  cwd = cwd or Path.cwd()
400
446
  if d := cls.work_dir(name, cwd, cls.path_depth, be_file):
@@ -404,7 +450,7 @@ class Project:
404
450
  raise EnvError(f"{name} not found! Make sure this is a poetry project.")
405
451
 
406
452
  @classmethod
407
- def load_toml_text(cls: type[Self], name=TOML_FILE) -> str:
453
+ def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str:
408
454
  toml_file = cls.get_work_dir(name, be_file=True)
409
455
  return toml_file.read_text("utf8")
410
456
 
@@ -421,7 +467,7 @@ class Project:
421
467
  else:
422
468
  for name in get_args(ToolName):
423
469
  if f"[tool.{name}]" in text:
424
- return name
470
+ return cast(ToolName, name)
425
471
  # Poetry 2.0 default to not include the '[tool.poetry]' section
426
472
  if cls.is_poetry_v2(text):
427
473
  return "poetry"
@@ -447,6 +493,12 @@ class ParseError(Exception):
447
493
 
448
494
 
449
495
  class UpgradeDependencies(Project, DryRun):
496
+ def __init__(
497
+ self: Self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry"
498
+ ) -> None:
499
+ super().__init__(_exit, dry)
500
+ self._tool = tool
501
+
450
502
  class DevFlag(StrEnum):
451
503
  new = "[tool.poetry.group.dev.dependencies]"
452
504
  old = "[tool.poetry.dev-dependencies]"
@@ -538,7 +590,7 @@ class UpgradeDependencies(Project, DryRun):
538
590
  return cls.DevFlag.new in text or cls.DevFlag.old in text
539
591
 
540
592
  @staticmethod
541
- def parse_item(toml_str) -> list[str]:
593
+ def parse_item(toml_str: str) -> list[str]:
542
594
  lines: list[str] = []
543
595
  for line in toml_str.splitlines():
544
596
  if (line := line.strip()).startswith("["):
@@ -608,20 +660,26 @@ class UpgradeDependencies(Project, DryRun):
608
660
  return _upgrade
609
661
 
610
662
  def gen(self: Self) -> str:
663
+ if self._tool == "uv":
664
+ return "uv lock --upgrade --verbose && uv sync --frozen"
665
+ elif self._tool == "pdm":
666
+ return "pdm update --verbose && pdm install"
611
667
  return self.gen_cmd() + " && poetry lock && poetry update"
612
668
 
613
669
 
614
670
  @cli.command()
615
671
  def upgrade(
672
+ tool: str = ToolOption,
616
673
  dry: bool = DryOption,
617
674
  ) -> None:
618
675
  """Upgrade dependencies in pyproject.toml to latest versions"""
619
- if (tool := Project.get_manage_tool()) == "uv":
620
- exit_if_run_failed("uv lock --upgrade && uv sync", dry=dry)
621
- elif tool == "pdm":
622
- exit_if_run_failed("pdm update && pdm install", dry=dry)
676
+ if not (tool := _ensure_str(tool)) or tool == ToolOption.default:
677
+ tool = Project.get_manage_tool() or "uv"
678
+ if tool in get_args(ToolName):
679
+ UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run()
623
680
  else:
624
- UpgradeDependencies(dry=dry).run()
681
+ secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW)
682
+ raise typer.Exit(1)
625
683
 
626
684
 
627
685
  class GitTag(DryRun):
@@ -676,19 +734,21 @@ def tag(
676
734
  class LintCode(DryRun):
677
735
  def __init__(
678
736
  self: Self,
679
- args,
680
- check_only=False,
681
- _exit=False,
682
- dry=False,
683
- bandit=False,
684
- skip_mypy=False,
685
- dmypy=False,
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,
686
745
  ) -> None:
687
746
  self.args = args
688
747
  self.check_only = check_only
689
748
  self._bandit = bandit
690
749
  self._skip_mypy = skip_mypy
691
750
  self._use_dmypy = dmypy
751
+ self._tool = tool
692
752
  super().__init__(_exit, dry)
693
753
 
694
754
  @staticmethod
@@ -696,7 +756,7 @@ class LintCode(DryRun):
696
756
  return check_call("ruff --version")
697
757
 
698
758
  @staticmethod
699
- def prefer_dmypy(paths: str, tools: list[str], use_dmypy=False) -> bool:
759
+ def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool:
700
760
  return (
701
761
  paths == "."
702
762
  and any(t.startswith("mypy") for t in tools)
@@ -706,7 +766,8 @@ class LintCode(DryRun):
706
766
  @staticmethod
707
767
  def get_package_name() -> str:
708
768
  root = Project.get_work_dir(allow_cwd=True)
709
- package_maybe = (root.name.replace("-", "_"), "src")
769
+ module_name = root.name.replace("-", "_").replace(" ", "_")
770
+ package_maybe = (module_name, "src")
710
771
  for name in package_maybe:
711
772
  if root.joinpath(name).is_dir():
712
773
  return name
@@ -720,6 +781,7 @@ class LintCode(DryRun):
720
781
  bandit: bool = False,
721
782
  skip_mypy: bool = False,
722
783
  use_dmypy: bool = False,
784
+ tool: str = ToolOption.default,
723
785
  ) -> str:
724
786
  if paths != "." and all(i.endswith(".html") for i in paths.split()):
725
787
  return f"prettier -w {paths}"
@@ -748,8 +810,11 @@ class LintCode(DryRun):
748
810
  secho(f"{tip}\n\n {command}\n", fg="yellow")
749
811
  else:
750
812
  should_run_by_tool = True
751
- if should_run_by_tool and (manage_tool := Project.get_manage_tool()):
752
- prefix = manage_tool + " run "
813
+ if should_run_by_tool and tool:
814
+ if tool == ToolOption.default:
815
+ tool = Project.get_manage_tool() or ""
816
+ if tool:
817
+ prefix = tool + " run "
753
818
  if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy):
754
819
  tools[-1] = "dmypy run"
755
820
  cmd += lint_them.format(prefix, paths, *tools)
@@ -766,7 +831,9 @@ class LintCode(DryRun):
766
831
  return cmd
767
832
 
768
833
  def gen(self: Self) -> str:
769
- paths = " ".join(map(str, self.args)) if self.args else "."
834
+ if isinstance(args := self.args, str):
835
+ args = args.split()
836
+ paths = " ".join(map(str, args)) if args else "."
770
837
  return self.to_cmd(
771
838
  paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy
772
839
  )
@@ -776,15 +843,31 @@ def parse_files(args: list[str] | tuple[str, ...]) -> list[str]:
776
843
  return [i for i in args if not i.startswith("-")]
777
844
 
778
845
 
779
- def lint(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> None:
846
+ def lint(
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:
780
854
  if files is None:
781
855
  files = parse_files(sys.argv[1:])
782
856
  if files and files[0] == "lint":
783
857
  files = files[1:]
784
- LintCode(files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy).run()
858
+ LintCode(
859
+ files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy, tool=tool
860
+ ).run()
785
861
 
786
862
 
787
- def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> None:
863
+ def check(
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:
788
871
  LintCode(
789
872
  files,
790
873
  check_only=True,
@@ -793,32 +876,35 @@ def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) ->
793
876
  bandit=bandit,
794
877
  skip_mypy=skip_mypy,
795
878
  dmypy=dmypy,
879
+ tool=tool,
796
880
  ).run()
797
881
 
798
882
 
799
883
  @cli.command(name="lint")
800
884
  def make_style(
801
- files: Optional[list[Path]] = typer.Argument(default=None), # noqa:B008
885
+ files: Optional[list[str]] = typer.Argument(default=None), # noqa:B008
802
886
  check_only: bool = Option(False, "--check-only", "-c"),
803
887
  bandit: bool = Option(False, "--bandit", help="Run `bandit -r <package_dir>`"),
804
888
  skip_mypy: bool = Option(False, "--skip-mypy"),
805
889
  use_dmypy: bool = Option(
806
890
  False, "--dmypy", help="Use `dmypy run` instead of `mypy`"
807
891
  ),
892
+ tool: str = ToolOption,
808
893
  dry: bool = DryOption,
809
894
  ) -> None:
810
895
  """Run: ruff check/format to reformat code and then mypy to check"""
811
896
  if getattr(files, "default", files) is None:
812
- files = [Path(".")]
897
+ files = ["."]
813
898
  elif isinstance(files, str):
814
899
  files = [files]
815
900
  skip = _ensure_bool(skip_mypy)
816
901
  dmypy = _ensure_bool(use_dmypy)
817
902
  bandit = _ensure_bool(bandit)
903
+ tool = _ensure_str(tool)
818
904
  if _ensure_bool(check_only):
819
- check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit)
905
+ check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
820
906
  else:
821
- lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit)
907
+ lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool)
822
908
 
823
909
 
824
910
  @cli.command(name="check")
@@ -832,7 +918,9 @@ def only_check(
832
918
 
833
919
 
834
920
  class Sync(DryRun):
835
- def __init__(self: Self, filename: str, extras: str, save: bool, dry=False) -> None:
921
+ def __init__(
922
+ self: Self, filename: str, extras: str, save: bool, dry: bool = False
923
+ ) -> None:
836
924
  self.filename = filename
837
925
  self.extras = extras
838
926
  self._save = save
@@ -846,23 +934,22 @@ class Sync(DryRun):
846
934
  raise EnvError("There project is not managed by uv/pdm/poetry!")
847
935
  return f"python -m pip install -r {self.filename}"
848
936
  prefix = "" if is_venv() else f"{tool} run "
849
- ensurepip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
850
- if tool == "uv":
851
- export_cmd = "uv export --no-hashes --all-extras --frozen"
852
- if check_call(prefix + "python -m pip --version"):
853
- ensurepip = ""
854
- elif tool in ("poetry", "pdm"):
937
+ ensure_pip = " {1}python -m ensurepip && {1}python -m pip install -U pip &&"
938
+ export_cmd = "uv export --no-hashes --all-extras --frozen"
939
+ if tool in ("poetry", "pdm"):
855
940
  export_cmd = f"{tool} export --without-hashes --with=dev"
856
941
  if tool == "poetry":
857
- ensurepip = ""
942
+ ensure_pip = ""
858
943
  if not UpgradeDependencies.should_with_dev():
859
944
  export_cmd = export_cmd.replace(" --with=dev", "")
860
945
  if extras and isinstance(extras, (str, list)):
861
946
  export_cmd += f" --{extras=}".replace("'", '"')
862
947
  elif check_call(prefix + "python -m pip --version"):
863
- ensurepip = ""
948
+ ensure_pip = ""
949
+ elif check_call(prefix + "python -m pip --version"):
950
+ ensure_pip = ""
864
951
  install_cmd = (
865
- f"{{2}} -o {{0}} &&{ensurepip} {{1}}python -m pip install -r {{0}}"
952
+ f"{{2}} -o {{0}} &&{ensure_pip} {{1}}python -m pip install -r {{0}}"
866
953
  )
867
954
  if should_remove and not save:
868
955
  install_cmd += " && rm -f {0}"
@@ -871,7 +958,7 @@ class Sync(DryRun):
871
958
 
872
959
  @cli.command()
873
960
  def sync(
874
- filename="dev_requirements.txt",
961
+ filename: str = "dev_requirements.txt",
875
962
  extras: str = Option("", "--extras", "-E"),
876
963
  save: bool = Option(
877
964
  False, "--save", "-s", help="Whether save the requirement file"
@@ -889,7 +976,7 @@ def _should_run_test_script(path: Path = Path("scripts")) -> Path | None:
889
976
  return None
890
977
 
891
978
 
892
- def test(dry: bool, ignore_script=False) -> None:
979
+ def test(dry: bool, ignore_script: bool = False) -> None:
893
980
  cwd = Path.cwd()
894
981
  root = Project.get_work_dir(cwd=cwd, allow_cwd=True)
895
982
  script_dir = root / "scripts"
@@ -944,13 +1031,13 @@ def dev(
944
1031
  port: int | None | OptionInfo,
945
1032
  host: str | None | OptionInfo,
946
1033
  file: str | None | ArgumentInfo = None,
947
- dry=False,
1034
+ dry: bool = False,
948
1035
  ) -> None:
949
1036
  cmd = "fastapi dev"
950
1037
  no_port_yet = True
951
1038
  if file is not None:
952
1039
  try:
953
- port = int(str(file)) # type:ignore[arg-type]
1040
+ port = int(str(file))
954
1041
  except ValueError:
955
1042
  cmd += f" {file}"
956
1043
  else:
@@ -34,14 +34,14 @@ classifiers = [
34
34
  dependencies = [
35
35
  "typer>=0.12.3,<1",
36
36
  "emoji >=2.12.1,<3",
37
+ "packaging>=20.5",
37
38
  "tomli>=2.0.1,<3; python_version < '3.11'",
38
39
  "coverage >=7.5.1,<8",
39
40
  "mypy >=1.15.0,<2",
40
41
  "bumpversion2 >=1.4.3,<2",
41
42
  "pytest >=8.2.0,<9",
42
- "packaging>=20.5",
43
43
  ]
44
- version = "0.14.2"
44
+ version = "0.15.1"
45
45
 
46
46
  [project.urls]
47
47
  Homepage = "https://github.com/waketzheng/fast-dev-cli"
@@ -124,6 +124,7 @@ pretty = true
124
124
  python_version = "3.9"
125
125
  ignore_missing_imports = true
126
126
  check_untyped_defs = true
127
+ warn_unused_ignores = true
127
128
  exclude = [
128
129
  "^fabfile\\.py$",
129
130
  "two\\.pyi$",
@@ -51,7 +51,12 @@ build-backend = "poetry.core.masonry.api"
51
51
 
52
52
 
53
53
  @pytest.fixture
54
- def tmp_poetry_project(tmp_path: Path):
54
+ def tmp_work_dir(tmp_path):
55
55
  with chdir(tmp_path):
56
- tmp_path.joinpath(TOML_FILE).write_text(TOML_CONTENT)
57
- yield
56
+ yield tmp_path
57
+
58
+
59
+ @pytest.fixture
60
+ def tmp_poetry_project(tmp_work_dir: Path):
61
+ Path(TOML_FILE).write_text(TOML_CONTENT)
62
+ yield tmp_work_dir
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import shutil
2
3
  import subprocess
3
4
  from contextlib import redirect_stdout
4
5
  from io import StringIO
@@ -200,3 +201,95 @@ def test_bump_with_uv(tmp_path):
200
201
  Path(TOML_FILE).write_text("[project]" + os.linesep + 'version = "0.1.0"')
201
202
  command = BumpUp(part="patch", commit=True).gen()
202
203
  assert TOML_FILE in command
204
+
205
+
206
+ def test_parse_filename(tmp_path):
207
+ pyproject = """
208
+ [tool.poetry]
209
+ version = "0"
210
+ """
211
+ project_dir = tmp_path / "helloworld"
212
+ project_dir.mkdir()
213
+ with chdir(project_dir):
214
+ toml_file = project_dir.joinpath(TOML_FILE)
215
+ toml_file.write_text(pyproject)
216
+ src_dir = project_dir / project_dir.name
217
+ src_dir.mkdir()
218
+ init_file = src_dir / "__init__.py"
219
+ init_file.write_text('__version__ = "0.1.0"')
220
+ another_dir = project_dir / "hello"
221
+ another_dir.mkdir()
222
+ shutil.copy(init_file, another_dir / init_file.name)
223
+ assert BumpUp.parse_filename() == "helloworld/__init__.py"
224
+ toml_file.write_text(pyproject.strip() + '\npackages=[{include="hello"}]')
225
+ assert BumpUp.parse_filename() == "hello/__init__.py"
226
+ toml_file.write_text(
227
+ pyproject.strip() + '\npackages=[{include="hello",from="py"}]'
228
+ )
229
+ from_dir = project_dir / "py"
230
+ from_dir.mkdir()
231
+ shutil.move(another_dir, from_dir)
232
+ assert BumpUp.parse_filename() == "py/hello/__init__.py"
233
+
234
+
235
+ PDM_DYNAMIC_VERSION = """
236
+ [tool.pdm.version]
237
+ source = "file"
238
+ path = "app/__init__.py"
239
+ """
240
+
241
+
242
+ def test_pdm_project(tmp_work_dir):
243
+ capture_cmd_output("pdm new my-project --non-interactive")
244
+ with chdir("my-project"):
245
+ toml_file = Path("pyproject.toml")
246
+ content = toml_file.read_text().replace(
247
+ 'version = "0.1.0"', 'dynamic = ["version"]'
248
+ )
249
+ toml_file.write_text(content)
250
+ with toml_file.open("a+") as f:
251
+ f.write(PDM_DYNAMIC_VERSION)
252
+ app = Path("app")
253
+ app.mkdir()
254
+ init_file = app.joinpath("__init__.py")
255
+ init_file.write_text('__version__ = "0.2.0"')
256
+ out = capture_cmd_output("fast bump patch")
257
+ assert str(init_file) in out
258
+ assert "0.2.1" in init_file.read_text()
259
+
260
+
261
+ def test_hatch_project(tmp_work_dir):
262
+ project_name = "httpx"
263
+ Path(project_name).mkdir()
264
+ with chdir(project_name):
265
+ toml_file = Path("pyproject.toml")
266
+ toml_file.write_text("""
267
+ [build-system]
268
+ requires = ["hatchling", "hatch-fancy-pypi-readme"]
269
+ build-backend = "hatchling.build"
270
+
271
+ [project]
272
+ name = "httpx"
273
+ description = "The next generation HTTP client."
274
+ license = "BSD-3-Clause"
275
+ requires-python = ">=3.8"
276
+ authors = [
277
+ { name = "Tom Christie", email = "tom@tomchristie.com" },
278
+ ]
279
+ dependencies = [
280
+ "certifi",
281
+ "httpcore==1.*",
282
+ "anyio",
283
+ "idna",
284
+ ]
285
+ dynamic = ["readme", "version"]
286
+
287
+ [tool.hatch.version]
288
+ path = "httpx/__version__.py"
289
+ """)
290
+ Path(project_name).mkdir()
291
+ Path("httpx/__version__.py").write_text("""
292
+ __title__ = "httpx"
293
+ __description__ = "A next generation HTTP client, for Python 3."
294
+ __version__ = "0.28.1"
295
+ """)
@@ -72,8 +72,10 @@ def test_run_shell():
72
72
  value = "foo"
73
73
  cmd = 'python -c "import os;print(list(os.environ))"'
74
74
  with redirect_stdout(StringIO()):
75
- r = exit_if_run_failed(cmd, env={name: value}, capture_output=True)
76
- assert name in r.stdout.decode()
75
+ r = exit_if_run_failed(
76
+ cmd, env={name: value}, capture_output=True, encoding="utf-8"
77
+ )
78
+ assert name in r.stdout
77
79
 
78
80
  assert run_and_echo("echo foo", capture_output=True) == 0
79
81
 
@@ -82,11 +82,21 @@ def test_check_bandit(tmp_path):
82
82
  src_dir = package_path / "src"
83
83
  if not src_dir.exists(): # For poetry<2.1
84
84
  src_dir = src_dir.parent / package_path.name
85
+ with chdir(package_path):
86
+ package_name = src_dir.name
87
+ assert f"bandit -r {package_name}" in LintCode.to_cmd(bandit=True)
88
+ toml_file = Path(TOML_FILE)
89
+ content = toml_file.read_text()
90
+ toml_file.write_text(content + '\n[tool.bandit]\nexclude_dirs = ["tests"]')
91
+ assert f"bandit -c {TOML_FILE} -r ." in LintCode.to_cmd(bandit=True)
85
92
  shutil.rmtree(src_dir)
86
93
  with chdir(package_path):
87
94
  assert LintCode.get_package_name() == "."
88
95
  command = capture_cmd_output("fast check --bandit --dry")
89
- assert "bandit -r ." in command
96
+ assert f"bandit -c {TOML_FILE} -r ." in command
97
+ toml_file.write_text(content)
98
+ command = capture_cmd_output("fast check --bandit --dry")
99
+ assert "bandit -r ." in command
90
100
 
91
101
 
92
102
  def test_check_skip_mypy(mock_skip_mypy_0, mocker, capsys):
@@ -140,6 +150,14 @@ def test_lint_html():
140
150
  assert "prettier -w index.html" in command
141
151
  command = capture_cmd_output(f"{lint_cmd} index.html flv.html --dry")
142
152
  assert "prettier -w index.html flv.html" in command
153
+ cmd = "fast lint index.html --dry"
154
+ assert "prettier -w index.html" in capture_cmd_output(cmd)
155
+ assert "prettier -w index.html" in capture_cmd_output("pdm run " + cmd)
156
+ cmd = "fast lint index.html flv.html --dry"
157
+ assert "prettier -w index.html flv.html" in capture_cmd_output(cmd)
158
+ assert "prettier -w index.html flv.html" in capture_cmd_output("pdm run " + cmd)
159
+ assert LintCode.to_cmd("index.html") == "prettier -w index.html"
160
+ assert LintCode.to_cmd("index.html flv.html") == "prettier -w index.html flv.html"
143
161
 
144
162
 
145
163
  def test_lint_by_global_fast():
@@ -151,20 +169,29 @@ def test_lint_by_global_fast():
151
169
 
152
170
 
153
171
  def test_with_dmypy():
154
- command = capture_cmd_output("fast lint --dmypy --dry .")
172
+ cmd = "fast lint --dmypy --dry ."
173
+ assert "dmypy run ." in capture_cmd_output(cmd)
174
+ assert "dmypy run ." in capture_cmd_output("pdm run " + cmd)
175
+ command = LintCode.to_cmd(use_dmypy=True, tool="pdm")
155
176
  assert "dmypy run ." in command
177
+ command = LintCode.to_cmd(use_dmypy=False, tool="pdm")
178
+ assert "dmypy run ." not in command
156
179
 
157
180
 
158
181
  def test_dmypy_run(monkeypatch):
182
+ command = capture_cmd_output("python -m fast_dev_cli lint --dry .")
183
+ assert "dmypy run ." not in command
159
184
  monkeypatch.setenv("FASTDEVCLI_DMYPY", "1")
160
185
  command = capture_cmd_output("python -m fast_dev_cli lint --dry .")
161
186
  assert "dmypy run ." in command
187
+ command = capture_cmd_output("python -m fast_dev_cli lint --skip-mypy --dry .")
188
+ assert "dmypy run ." not in command
162
189
 
163
190
 
164
191
  def test_lint_with_prefix(mocker):
165
192
  mocker.patch("fast_dev_cli.cli.is_venv", return_value=False)
166
193
  with capture_stdout() as stream:
167
- make_style([Path(".")], check_only=False, dry=True)
194
+ make_style(["."], check_only=False, dry=True)
168
195
  assert "pdm run" in stream.getvalue()
169
196
 
170
197
 
@@ -174,13 +201,13 @@ def test_make_style(mock_skip_mypy_0, mocker, mock_no_dmypy):
174
201
  make_style(check_only=False, dry=True)
175
202
  assert LINT_CMD in stream.getvalue()
176
203
  with capture_stdout() as stream:
177
- make_style([Path(".")], check_only=False, dry=True)
204
+ make_style(["."], check_only=False, dry=True)
178
205
  assert LINT_CMD in stream.getvalue()
179
206
  with capture_stdout() as stream:
180
207
  make_style(".", check_only=False, dry=True) # type:ignore[arg-type]
181
208
  assert LINT_CMD in stream.getvalue()
182
209
  with capture_stdout() as stream:
183
- make_style([Path(".")], check_only=True, dry=True)
210
+ make_style(["."], check_only=True, dry=True)
184
211
  assert CHECK_CMD in stream.getvalue()
185
212
  with capture_stdout() as stream:
186
213
  only_check(dry=True)
@@ -297,3 +324,24 @@ def test_get_manage_tool(tmp_path):
297
324
  assert Project.get_manage_tool() == "pdm"
298
325
  Path(TOML_FILE).write_text("[tool.uv]")
299
326
  assert Project.get_manage_tool() == "uv"
327
+
328
+
329
+ class TestGetPackageName:
330
+ project = "hello-world"
331
+
332
+ def test_get_package_name(self, tmp_path):
333
+ project_dir = tmp_path / self.project
334
+ project_dir.mkdir()
335
+ module_name = project_dir.name.replace("-", "_").replace(" ", "_")
336
+ with chdir(project_dir):
337
+ Path(TOML_FILE).touch()
338
+ Path(module_name).mkdir()
339
+ assert LintCode.get_package_name() == module_name
340
+ Path("src").mkdir()
341
+ assert LintCode.get_package_name() == module_name
342
+ shutil.rmtree(module_name)
343
+ assert LintCode.get_package_name() == "src"
344
+
345
+
346
+ class TestGetPackageNameWithSpace(TestGetPackageName):
347
+ project = "hello world"
@@ -6,9 +6,14 @@ from contextlib import redirect_stdout
6
6
  from io import StringIO
7
7
  from pathlib import Path
8
8
 
9
+ import pytest
10
+ import typer
11
+
12
+ import fast_dev_cli
9
13
  from fast_dev_cli.cli import (
10
14
  TOML_FILE,
11
15
  UpgradeDependencies,
16
+ capture_cmd_output,
12
17
  run_and_echo,
13
18
  upgrade,
14
19
  )
@@ -283,3 +288,34 @@ fastapi = "^0.112.2"
283
288
  [],
284
289
  "--dev",
285
290
  )
291
+
292
+
293
+ def test_upgrade_uv_project():
294
+ cmd = "fast upgrade --tool=uv --dry"
295
+ expected = "uv lock --upgrade --verbose && uv sync --frozen"
296
+ assert expected in capture_cmd_output(cmd)
297
+ assert expected in capture_cmd_output("pdm run " + cmd)
298
+ assert UpgradeDependencies(tool="uv").gen() == expected
299
+
300
+
301
+ def test_upgrade_pdm_project():
302
+ cmd = "fast upgrade --tool=pdm --dry"
303
+ expected = "pdm update --verbose && pdm install"
304
+ assert expected in capture_cmd_output(cmd)
305
+ assert expected in capture_cmd_output("pdm run " + cmd)
306
+ assert UpgradeDependencies(tool="pdm").gen() == expected
307
+
308
+
309
+ def test_upgrade_unknown_tool(mocker):
310
+ cmd = "fast upgrade --tool=hatch --dry"
311
+ expected = "Unknown tool 'hatch'"
312
+ assert expected in capture_cmd_output(cmd)
313
+ assert expected in capture_cmd_output("pdm run " + cmd)
314
+ assert run_and_echo(cmd, verbose=False) == 1
315
+ assert run_and_echo("pdm run " + cmd, verbose=False) == 1
316
+ mocker.patch("fast_dev_cli.cli.secho")
317
+ with pytest.raises(typer.Exit):
318
+ upgrade(tool="pipenv")
319
+ fast_dev_cli.cli.secho.assert_called_once_with( # type:ignore
320
+ "Unknown tool 'pipenv'", fg=typer.colors.YELLOW
321
+ )
@@ -1,28 +1,25 @@
1
1
  import os
2
2
  import subprocess
3
3
  import sys
4
- from contextlib import contextmanager, redirect_stdout
4
+ from contextlib import AbstractContextManager, contextmanager, redirect_stdout
5
5
  from io import StringIO
6
6
  from pathlib import Path
7
7
 
8
- if sys.version_info >= (3, 11):
9
- from contextlib import chdir # type:ignore[attr-defined]
10
- else:
11
- from contextlib import AbstractContextManager
12
8
 
13
- class chdir(AbstractContextManager): # type:ignore[no-redef]
14
- """Non thread-safe context manager to change the current working directory."""
9
+ # TODO: use `from contextlib import chdir` instead when drop support for Python3.10
10
+ class chdir(AbstractContextManager): # Copied from source code of Python3.13
11
+ """Non thread-safe context manager to change the current working directory."""
15
12
 
16
- def __init__(self, path):
17
- self.path = path
18
- self._old_cwd = []
13
+ def __init__(self, path):
14
+ self.path = path
15
+ self._old_cwd = []
19
16
 
20
- def __enter__(self):
21
- self._old_cwd.append(os.getcwd())
22
- os.chdir(self.path)
17
+ def __enter__(self):
18
+ self._old_cwd.append(os.getcwd())
19
+ os.chdir(self.path)
23
20
 
24
- def __exit__(self, *excinfo):
25
- os.chdir(self._old_cwd.pop())
21
+ def __exit__(self, *excinfo):
22
+ os.chdir(self._old_cwd.pop())
26
23
 
27
24
 
28
25
  __all__ = (
@@ -1 +0,0 @@
1
- __version__ = "0.14.2"
File without changes
File without changes