diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4c9e22d3c..71752e8fe6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,7 @@ jobs: working-directory: ${{ env.SDK_PYTHON }} run: | uv run --no-dev --group test pytest packages/flet/tests + uv run --no-dev --group test pytest packages/flet-cli/tests - name: Run docs-coverage if: matrix.python-version == '3.12' diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/project_dependencies.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/project_dependencies.py index 2cae6d72d0..5e0d6f5536 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/project_dependencies.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/project_dependencies.py @@ -2,8 +2,86 @@ # Based on: https://pypi.org/project/toml-to-requirements/ +import re from typing import Any, Optional +from packaging.requirements import Requirement + + +def _windows_safe(req_str: str) -> str: + """Insert a space before bare `<` or `>` so Windows cmd.exe does not + interpret them as shell redirection when the string is passed via `-r` + to a `.BAT` subprocess.""" + return re.sub(r"(?<=[^ ])([<>])", r" \1", req_str) + + +def _poetry_version_to_pep440(version: str) -> str: + """Convert a Poetry version constraint to PEP 440 syntax. + + - `^1.2.3` → `>=1.2.3` + - `~1.2.3` → `~=1.2.3` (`~=` passes through unchanged) + - `*` → `""` (no constraint) + - `1.2.3` (bare version) → `==1.2.3` + - Anything else is returned as-is (already PEP 440). + """ + version = version.replace(" ", "") + if not version or version == "*": + return "" + if version.startswith("^"): + return f">={version[1:]}" + if version.startswith("~") and not version.startswith("~="): + return f"~={version[1:]}" + # Bare version number → pin with == + if version[0].isdigit(): + return f"=={version}" + return version + + +def _poetry_dep_to_pep508(name: str, value: Any) -> str: + """Convert a single Poetry dependency entry to a PEP 508 requirement string.""" + suffix = "" + + if isinstance(value, dict): + version = value.get("version") + if version: + specifier = _poetry_version_to_pep440(version) + markers = value.get("markers") + if markers is not None: + suffix = f"; {markers}" + if specifier: + return f"{name}{specifier}{suffix}" + return f"{name}{suffix}" + + git_url = value.get("git") + if git_url: + url = f"git+{git_url}" if not git_url.startswith("git@") else git_url + rev = value.get("branch") or value.get("rev") or value.get("tag") + if rev: + url = f"{url}@{rev}" + subdirectory = value.get("subdirectory") + if subdirectory: + url = f"{url}#subdirectory={subdirectory}" + markers = value.get("markers") + if markers is not None: + suffix = f"; {markers}" + return f"{name} @ {url}{suffix}" + + path = value.get("path") + if path: + return path + + url = value.get("url") + if url: + return url + + raise ValueError(f"Unsupported dependency specification: {name} = {value}") + + # String value + specifier = _poetry_version_to_pep440(value) + if specifier: + return f"{name}{specifier}" + return name + def get_poetry_dependencies( poetry_dependencies: Optional[dict[str, Any]] = None, @@ -17,103 +95,20 @@ def get_poetry_dependencies( Returns: Sorted requirement strings or `None` when `poetry_dependencies` is `None`. """ - if poetry_dependencies is None: return None - def format_dependency_version(dependency_name: str, dependency_value: Any): - """ - Format a single Poetry dependency entry as a requirement specifier. - - Supports version constraints, git dependencies (including branch/rev/tag - and subdirectory), path/url dependencies, and optional environment markers. - - Args: - dependency_name: Dependency key in Poetry configuration. - dependency_value: String or mapping that describes the dependency. - - Returns: - A requirement string consumable by pip-style tooling. - - Raises: - ValueError: If the dependency mapping uses an unsupported shape. - """ - - sep = "@" - value = "" - suffix = "" - - if isinstance(dependency_value, dict): - version = dependency_value.get("version") - if version: - sep = "==" - value = version - else: - git_url = dependency_value.get("git") - if git_url: - value = ( - f"git+{git_url}" if not git_url.startswith("git@") else git_url - ) - rev = ( - dependency_value.get("branch") - or dependency_value.get("rev") - or dependency_value.get("tag") - ) - if rev: - value = f"{value}@{rev}" - subdirectory = dependency_value.get("subdirectory") - if subdirectory: - value = f"{value}#subdirectory={subdirectory}" - else: - path = dependency_value.get("path") - if path: - value = path - dependency_name = "" - sep = "" - else: - url = dependency_value.get("url") - if url: - value = url - dependency_name = "" - sep = "" - else: - raise ValueError( - "Unsupported dependency specification: " - f"{dependency_name} = {dependency_value}" - ) - - # markers - common for all - markers = dependency_value.get("markers") - if markers is not None: - suffix = f";{markers}" - else: - value = dependency_value - sep = "==" - - if value.startswith("^"): - sep = ">=" - value = value[1:] - elif value.startswith("~"): - sep = "~=" - value = value[1:] - return f"{dependency_name}~={value[1:]}" - elif "<" in value or ">" in value: - sep = "" - value = value.replace(" ", "") - - return f"{dependency_name}{sep}{value}{suffix}" - dependencies: set[str] = { - format_dependency_version(dependency, version) - for dependency, version in poetry_dependencies.items() - if dependency != "python" + _windows_safe(_poetry_dep_to_pep508(dep, ver)) + for dep, ver in poetry_dependencies.items() + if dep != "python" } return sorted(dependencies) def get_project_dependencies( - project_dependencies: Optional[dict[str, Any]] = None, + project_dependencies: Optional[list[str]] = None, ) -> Optional[list[str]]: """ Normalize PEP 621 `project.dependencies` into a sorted unique list. @@ -124,10 +119,15 @@ def get_project_dependencies( Returns: Sorted dependency strings, or `None` when input is `None`. """ - if project_dependencies is None: return None - dependencies = set(project_dependencies) + dependencies: set[str] = set() + for dep in project_dependencies: + try: + req = Requirement(dep) + dependencies.add(_windows_safe(str(req))) + except Exception: + dependencies.add(_windows_safe(dep)) return sorted(dependencies) diff --git a/sdk/python/packages/flet-cli/tests/test_project_dependencies.py b/sdk/python/packages/flet-cli/tests/test_project_dependencies.py new file mode 100644 index 0000000000..900b575b29 --- /dev/null +++ b/sdk/python/packages/flet-cli/tests/test_project_dependencies.py @@ -0,0 +1,241 @@ +"""Tests for project_dependencies module.""" + +import pytest + +from flet_cli.utils.project_dependencies import ( + get_poetry_dependencies, + get_project_dependencies, +) + +# --------------------------------------------------------------------------- +# get_poetry_dependencies +# --------------------------------------------------------------------------- + + +class TestGetPoetryDependencies: + def test_none_returns_none(self): + assert get_poetry_dependencies(None) is None + + def test_empty_dict(self): + assert get_poetry_dependencies({}) == [] + + def test_python_excluded(self): + assert get_poetry_dependencies({"python": "^3.10"}) == [] + + def test_exact_version(self): + result = get_poetry_dependencies({"packaging": "26.0"}) + assert result == ["packaging==26.0"] + + def test_caret(self): + result = get_poetry_dependencies({"urllib3": "^2.6.0"}) + assert result == ["urllib3 >=2.6.0"] + + def test_tilde(self): + result = get_poetry_dependencies({"setuptools": "~82.0.0"}) + assert result == ["setuptools~=82.0.0"] + + def test_tilde_equals_passthrough(self): + result = get_poetry_dependencies({"setuptools": "~=82.0.0"}) + assert result == ["setuptools~=82.0.0"] + + def test_wildcard(self): + result = get_poetry_dependencies({"boto3": "*"}) + assert result == ["boto3"] + + def test_less_than(self): + result = get_poetry_dependencies({"chardet": "<6"}) + assert result == ["chardet <6"] + + def test_less_than_equal(self): + result = get_poetry_dependencies({"requests": "<=2.32.4"}) + assert result == ["requests <=2.32.4"] + + def test_greater_than_equal(self): + result = get_poetry_dependencies({"certifi": ">=2026.1.4"}) + assert result == ["certifi >=2026.1.4"] + + def test_range_constraint(self): + result = get_poetry_dependencies({"pydantic": ">=2.9.0,<3.0.0"}) + # _windows_safe ensures spaces before < and > + assert "pydantic" in result[0] + assert " >=" in result[0] + assert " <" in result[0] + + def test_not_equal_combined(self): + result = get_poetry_dependencies({"pandas": ">=2.3,!=2.3.3"}) + assert "pandas" in result[0] + assert " >=" in result[0] + assert "!=" in result[0] + + def test_spaces_in_version_stripped(self): + result = get_poetry_dependencies({"chardet": " < 6 "}) + assert result == ["chardet <6"] + + def test_dict_version(self): + result = get_poetry_dependencies( + {"scipy": {"version": "^1.16", "optional": True}} + ) + assert result == ["scipy >=1.16"] + + def test_dict_version_with_markers(self): + result = get_poetry_dependencies( + {"pywin32": {"version": ">=310", "markers": "sys_platform == 'win32'"}} + ) + assert result == ["pywin32 >=310; sys_platform == 'win32'"] + + def test_dict_git(self): + result = get_poetry_dependencies( + {"numpy": {"git": "https://github.com/numpy/numpy.git", "branch": "main"}} + ) + assert result == ["numpy @ git+https://github.com/numpy/numpy.git@main"] + + def test_dict_git_ssh(self): + result = get_poetry_dependencies( + {"mylib": {"git": "git@github.com:org/repo.git", "tag": "v1.0"}} + ) + assert result == ["mylib @ git@github.com:org/repo.git@v1.0"] + + def test_dict_git_subdirectory(self): + result = get_poetry_dependencies( + { + "mylib": { + "git": "https://github.com/org/mono.git", + "branch": "main", + "subdirectory": "packages/mylib", + } + } + ) + assert "subdirectory=packages/mylib" in result[0] + + def test_dict_path(self): + result = get_poetry_dependencies({"mylib": {"path": "../mylib"}}) + assert result == ["../mylib"] + + def test_dict_url(self): + result = get_poetry_dependencies( + {"mylib": {"url": "https://example.com/mylib.tar.gz"}} + ) + assert result == ["https://example.com/mylib.tar.gz"] + + def test_dict_unsupported_raises(self): + with pytest.raises(ValueError, match="Unsupported"): + get_poetry_dependencies({"bad": {"extras": ["foo"]}}) + + def test_sorted_output(self): + result = get_poetry_dependencies({"zlib": "1.0", "aiohttp": "3.0"}) + assert result == ["aiohttp==3.0", "zlib==1.0"] + + def test_multiple_deps(self): + deps = { + "python": "^3.10", + "boto3": "*", + "chardet": "<6", + "packaging": "26.0", + } + result = get_poetry_dependencies(deps) + assert "boto3" in result + assert any("chardet" in r for r in result) + assert any("packaging" in r for r in result) + # python should be excluded + assert not any("python" in r for r in result) + + +# --------------------------------------------------------------------------- +# get_project_dependencies +# --------------------------------------------------------------------------- + + +class TestGetProjectDependencies: + def test_none_returns_none(self): + assert get_project_dependencies(None) is None + + def test_empty_list(self): + assert get_project_dependencies([]) == [] + + def test_simple_dep(self): + result = get_project_dependencies(["boto3"]) + assert result == ["boto3"] + + def test_exact_version(self): + result = get_project_dependencies(["packaging==26.0"]) + assert result == ["packaging==26.0"] + + def test_gte(self): + result = get_project_dependencies(["flet>=0.82.0"]) + assert result == ["flet >=0.82.0"] + + def test_lt_gets_space(self): + result = get_project_dependencies(["chardet<6"]) + assert result == ["chardet <6"] + + def test_lte_gets_space(self): + result = get_project_dependencies(["requests<=2.32.4"]) + assert result == ["requests <=2.32.4"] + + def test_gt_gets_space(self): + result = get_project_dependencies(["chardet>3"]) + assert result == ["chardet >3"] + + def test_combined_constraints(self): + result = get_project_dependencies(["pydantic>=2.9.0,<3.0.0"]) + assert len(result) == 1 + dep = result[0] + assert "pydantic" in dep + assert " <" in dep or " >" in dep + + def test_not_equal(self): + result = get_project_dependencies(["pandas>=2.3,!=2.3.3"]) + assert len(result) == 1 + assert " >=" in result[0] + assert "!=" in result[0] + + def test_compatible_release(self): + result = get_project_dependencies(["setuptools~=82.0.0"]) + assert result == ["setuptools~=82.0.0"] + + def test_extras(self): + result = get_project_dependencies(["uvicorn[standard]>=0.42.0"]) + dep = result[0] + assert "uvicorn" in dep + assert "[standard]" in dep + assert " >=" in dep + + def test_markers_preserved(self): + result = get_project_dependencies(['pywin32>=310; sys_platform == "win32"']) + assert len(result) == 1 + assert "sys_platform" in result[0] + assert "win32" in result[0] + assert " >=" in result[0] + + def test_url_dep(self): + result = get_project_dependencies( + ["numpy @ git+https://github.com/numpy/numpy.git@main"] + ) + assert len(result) == 1 + assert "git+https://github.com/numpy/numpy.git@main" in result[0] + + def test_extra_spaces_normalized(self): + result = get_project_dependencies(["chardet < 6"]) + assert result == ["chardet <6"] + + def test_sorted_and_deduplicated(self): + result = get_project_dependencies(["zlib>=1.0", "aiohttp>=3.0", "zlib>=1.0"]) + assert result[0].startswith("aiohttp") + assert len(result) == 2 + + def test_downstream_compatible(self): + """Output must be parseable by packaging.requirements.Requirement, + since build_base.py does Requirement(dep).name on our output.""" + from packaging.requirements import Requirement + + deps = [ + "flet>=0.82.0", + "chardet<6", + "pydantic>=2.9.0,<3.0.0", + 'pywin32>=310; sys_platform == "win32"', + "uvicorn[standard]>=0.42.0", + ] + result = get_project_dependencies(deps) + for dep in result: + req = Requirement(dep) + assert req.name # must parse without error