diff --git a/pyproject.toml b/pyproject.toml index bbbb9ccde..4e9c83f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ dependencies = [ "colorlog>=6.9.0", "langsmith", "langchain-xai>=0.2.1", + "codegen-sdk-pink>=0.1.0", ] license = { text = "Apache-2.0" } @@ -172,6 +173,7 @@ dev-dependencies = [ [tool.uv.workspace] exclude = ["codegen-examples"] + [tool.cython-lint] max-line-length = 200 diff --git a/src/codegen/configs/models/codebase.py b/src/codegen/configs/models/codebase.py index b8484e63b..87153c162 100644 --- a/src/codegen/configs/models/codebase.py +++ b/src/codegen/configs/models/codebase.py @@ -1,8 +1,19 @@ +from enum import IntEnum, auto + from pydantic import Field from codegen.configs.models.base_config import BaseConfig +class PinkMode(IntEnum): + # Use the python SDK for all files + OFF = auto() + # Use the Rust SDK for all files + ALL_FILES = auto() + # Use the Rust SDK for files the python SDK can't parse (non-source files) + NON_SOURCE_FILES = auto() + + class CodebaseConfig(BaseConfig): def __init__(self, prefix: str = "CODEBASE", *args, **kwargs) -> None: super().__init__(prefix=prefix, *args, **kwargs) @@ -25,6 +36,7 @@ def __init__(self, prefix: str = "CODEBASE", *args, **kwargs) -> None: ts_language_engine: bool = False v8_ts_engine: bool = False unpacking_assignment_partial_removal: bool = True + use_pink: PinkMode = PinkMode.OFF DefaultCodebaseConfig = CodebaseConfig() diff --git a/src/codegen/sdk/codebase/codebase_context.py b/src/codegen/sdk/codebase/codebase_context.py index 7f1fdc29e..cb96150ff 100644 --- a/src/codegen/sdk/codebase/codebase_context.py +++ b/src/codegen/sdk/codebase/codebase_context.py @@ -11,7 +11,7 @@ from rustworkx import PyDiGraph, WeightedEdgeList -from codegen.configs.models.codebase import CodebaseConfig +from codegen.configs.models.codebase import CodebaseConfig, PinkMode from codegen.configs.models.secrets import SecretsConfig from codegen.sdk.codebase.config import ProjectConfig, SessionOptions from codegen.sdk.codebase.config_parser import ConfigParser, get_config_parser_for_language @@ -189,7 +189,7 @@ def __init__( logger.warning("Some features may not work as expected. Advanced static analysis will be disabled but simple file IO will still work.") # Build the graph - if not self.config.exp_lazy_graph: + if not self.config.exp_lazy_graph and self.config.use_pink != PinkMode.ALL_FILES: self.build_graph(context.repo_operator) try: self.synced_commit = context.repo_operator.head_commit diff --git a/src/codegen/sdk/core/codebase.py b/src/codegen/sdk/core/codebase.py index 2c641e655..bfc81e2b6 100644 --- a/src/codegen/sdk/core/codebase.py +++ b/src/codegen/sdk/core/codebase.py @@ -22,7 +22,7 @@ from rich.console import Console from typing_extensions import TypeVar, deprecated -from codegen.configs.models.codebase import CodebaseConfig +from codegen.configs.models.codebase import CodebaseConfig, PinkMode from codegen.configs.models.secrets import SecretsConfig from codegen.git.repo_operator.repo_operator import RepoOperator from codegen.git.schemas.enums import CheckoutResult, SetupOption @@ -212,6 +212,10 @@ def __init__( self.repo_path = Path(self._op.repo_path) self.ctx = CodebaseContext(projects, config=config, secrets=secrets, io=io, progress=progress) self.console = Console(record=True, soft_wrap=True) + if self.ctx.config.use_pink != PinkMode.OFF: + import codegen_sdk_pink + + self._pink_codebase = codegen_sdk_pink.Codebase(self.repo_path) @noapidoc def __str__(self) -> str: @@ -297,6 +301,8 @@ def files(self, *, extensions: list[str] | Literal["*"] | None = None) -> list[T Returns: list[TSourceFile]: A sorted list of source files in the codebase. """ + if self.ctx.config.use_pink == PinkMode.ALL_FILES: + return self._pink_codebase.files if extensions is None and len(self.ctx.get_nodes(NodeType.FILE)) > 0: # If extensions is None AND there is at least one file in the codebase (This checks for unsupported languages or parse-off repos), # Return all source files @@ -528,6 +534,12 @@ def has_file(self, filepath: str, ignore_case: bool = False) -> bool: Returns: bool: True if the file exists in the codebase, False otherwise. """ + if self.ctx.config.use_pink == PinkMode.ALL_FILES: + absolute_path = self.ctx.to_absolute(filepath) + return self._pink_codebase.has_file(absolute_path) + if self.ctx.config.use_pink == PinkMode.NON_SOURCE_FILES: + if self._pink_codebase.has_file(filepath): + return True return self.get_file(filepath, optional=True, ignore_case=ignore_case) is not None @overload @@ -550,13 +562,20 @@ def get_file(self, filepath: str, *, optional: bool = False, ignore_case: bool = Raises: ValueError: If file not found and optional=False. """ + if self.ctx.config.use_pink == PinkMode.ALL_FILES: + absolute_path = self.ctx.to_absolute(filepath) + return self._pink_codebase.get_file(absolute_path) # Try to get the file from the graph first file = self.ctx.get_file(filepath, ignore_case=ignore_case) if file is not None: return file + # If the file is not in the graph, check the filesystem absolute_path = self.ctx.to_absolute(filepath) if self.ctx.io.file_exists(absolute_path): + if self.ctx.config.use_pink != PinkMode.OFF: + if file := self._pink_codebase.get_file(absolute_path): + return file return self.ctx._get_raw_file_from_path(absolute_path) # If the file is not in the graph, check the filesystem if absolute_path.parent.exists(): diff --git a/tests/unit/codegen/sdk/codebase/file/test_file_pink.py b/tests/unit/codegen/sdk/codebase/file/test_file_pink.py new file mode 100644 index 000000000..8468a029a --- /dev/null +++ b/tests/unit/codegen/sdk/codebase/file/test_file_pink.py @@ -0,0 +1,191 @@ +import os +import sys + +import pytest + +from codegen.configs.models.codebase import PinkMode +from codegen.sdk.codebase.config import TestFlags +from codegen.sdk.codebase.factory.get_session import get_codebase_session +from codegen.sdk.core.file import File, SourceFile +from codegen.shared.enums.programming_language import ProgrammingLanguage + +Config = TestFlags.model_copy(update=dict(use_pink=PinkMode.ALL_FILES)) + + +@pytest.mark.xfail(reason="Blocked on CG-11949") +def test_file(tmpdir) -> None: + file1_source = "Hello world!" + file2_source = "print(123)" + file3_source = b"\x89PNG" + with get_codebase_session(tmpdir=tmpdir, files={"file1.txt": file1_source, "file2.py": file2_source, "file3.bin": file3_source}, config=Config) as codebase: + file1 = codebase.get_file("file1.txt") + assert isinstance(file1, File) + assert not isinstance(file1, SourceFile) + assert file1 is not None + assert file1.filepath == "file1.txt" + assert file1.content == file1_source + assert file1.is_binary is False + + file2 = codebase.get_file("file2.py") + assert isinstance(file2, SourceFile) + assert file2 is not None + assert file2.filepath == "file2.py" + assert file2.content == file2_source + assert file2.is_binary is False + + file3 = codebase.get_file("file3.bin") + assert isinstance(file3, File) + assert not isinstance(file3, SourceFile) + assert file3 is not None + assert file3.filepath == "file3.bin" + assert file3.is_binary is True + assert file3.content_bytes == file3_source + with pytest.raises(ValueError): + codebase.get_file("file4.txt") + with pytest.raises(ValueError): + codebase.get_directory("file4/") + + +@pytest.mark.xfail(reason="Blocked on CG-11949") +def test_codebase_files(tmpdir) -> None: + with get_codebase_session(tmpdir=tmpdir, files={"file1.py": "print(123)", "file2.py": "print(456)", "file3.bin": b"\x89PNG", "file4": "Hello world!"}, config=Config) as codebase: + file1 = codebase.get_file("file1.py") + file2 = codebase.get_file("file2.py") + file3 = codebase.get_file("file3.bin") + file4 = codebase.get_file("file4") + + assert len(codebase.files) == 2 + assert {f for f in codebase.files} == {file1, file2} + + assert len(codebase.files(extensions="*")) == 4 + assert {f for f in codebase.files(extensions="*")} == {file1, file2, file3, file4} + + assert len(codebase.files(extensions=[".py"])) == 2 + assert {f for f in codebase.files(extensions=[".py"])} == {file1, file2} + + assert len(codebase.files(extensions=[".bin"])) == 1 + assert {f for f in codebase.files(extensions=[".bin"])} == {file3} + + +@pytest.mark.xfail(reason="Blocked on CG-11949") +def test_codebase_files_other_language(tmpdir) -> None: + with get_codebase_session( + tmpdir=tmpdir, files={"file1.py": "print(123)", "file2.py": "print(456)", "file3.bin": b"\x89PNG", "file4": "Hello world!"}, programming_language=ProgrammingLanguage.OTHER, config=Config + ) as codebase: + file1 = codebase.get_file("file1.py") + file2 = codebase.get_file("file2.py") + file3 = codebase.get_file("file3.bin") + file4 = codebase.get_file("file4") + + assert len(codebase.files) == 4 # Match all files if the language is OTHER + assert {f for f in codebase.files} == {file1, file2, file3, file4} + + assert len(codebase.files(extensions="*")) == 4 + assert {f for f in codebase.files(extensions="*")} == {file1, file2, file3, file4} + + assert len(codebase.files(extensions=[".py"])) == 2 + assert {f for f in codebase.files(extensions=[".py"])} == {file1, file2} + + assert len(codebase.files(extensions=[".bin"])) == 1 + assert {f for f in codebase.files(extensions=[".bin"])} == {file3} + + +@pytest.mark.skipif(sys.platform == "darwin", reason="macOS is case-insensitive") +@pytest.mark.xfail(reason="Blocked on CG-11949") +def test_file_extensions_ignore_case(tmpdir) -> None: + with get_codebase_session(tmpdir=tmpdir, files={"file1.py": "print(123)", "file2.py": "print(456)", "file3.bin": b"\x89PNG", "file4": "Hello world!"}, config=Config) as codebase: + file1 = codebase.get_file("file1.py") + file2 = codebase.get_file("file2.py") + file3 = codebase.get_file("file3.bin") + file4 = codebase.get_file("file4") + + assert len(codebase.files(extensions=[".pyi"])) == 0 + assert {f for f in codebase.files(extensions=[".pyi"])} == set() + # Test ignore_case + file1_upper = codebase.get_file("FILE1.PY", ignore_case=True) + assert file1_upper is not None + assert file1_upper == file1 + + file2_mixed = codebase.get_file("FiLe2.Py", ignore_case=True) + assert file2_mixed is not None + assert file2_mixed == file2 + + file3_upper = codebase.get_file("FILE3.BIN", ignore_case=True) + assert file3_upper is not None + assert file3_upper == file3 + + # Test ignore_case=False (default) + assert codebase.get_file("FILE1.PY", ignore_case=False, optional=True) is None + assert codebase.get_file("FiLe2.Py", ignore_case=False, optional=True) is None + assert codebase.get_file("FILE3.BIN", ignore_case=False, optional=True) is None + + +@pytest.mark.skipif(sys.platform == "darwin", reason="macOS is case-insensitive") +@pytest.mark.xfail(reason="Blocked on CG-11949") +def test_file_case_sensitivity_has_file(tmpdir) -> None: + with get_codebase_session(tmpdir=tmpdir, files={"file1.py": "print(123)", "file2.py": "print(456)", "file3.bin": b"\x89PNG"}, config=Config) as codebase: + # Test has_file with ignore_case=True + assert codebase.has_file("file1.py", ignore_case=True) + assert codebase.has_file("FILE1.PY", ignore_case=True) + assert codebase.has_file("FiLe1.Py", ignore_case=True) + assert codebase.has_file("file2.py", ignore_case=True) + assert codebase.has_file("FILE2.PY", ignore_case=True) + assert codebase.has_file("FiLe2.Py", ignore_case=True) + assert codebase.has_file("file3.bin", ignore_case=True) + assert codebase.has_file("FILE3.BIN", ignore_case=True) + assert codebase.has_file("FiLe3.BiN", ignore_case=True) + + # Test has_file with ignore_case=False (default) + assert codebase.has_file("file1.py", ignore_case=False) + assert not codebase.has_file("FILE1.PY", ignore_case=False) + assert not codebase.has_file("FiLe1.Py", ignore_case=False) + assert codebase.has_file("file2.py", ignore_case=False) + assert not codebase.has_file("FILE2.PY", ignore_case=False) + assert not codebase.has_file("FiLe2.Py", ignore_case=False) + assert codebase.has_file("file3.bin", ignore_case=False) + assert not codebase.has_file("FILE3.BIN", ignore_case=False) + assert not codebase.has_file("FiLe3.BiN", ignore_case=False) + + +@pytest.mark.skipif(sys.platform == "darwin", reason="macOS is case-insensitive") +@pytest.mark.xfail(reason="Blocked on CG-11949") +def test_file_case_sensitivity_get_file(tmpdir) -> None: + with get_codebase_session(tmpdir=tmpdir, files={"file1.py": "print(123)", "file2.py": "print(456)", "file3.bin": b"\x89PNG"}, config=Config) as codebase: + file1 = codebase.get_file("file1.py") + file2 = codebase.get_file("file2.py") + file3 = codebase.get_file("file3.bin") + + # Test get_file with ignore_case=True + assert codebase.get_file("FILE1.PY", ignore_case=True) == file1 + assert codebase.get_file("FiLe1.Py", ignore_case=True) == file1 + assert codebase.get_file("FILE2.PY", ignore_case=True) == file2 + assert codebase.get_file("FiLe2.Py", ignore_case=True) == file2 + assert codebase.get_file("FILE3.BIN", ignore_case=True) == file3 + assert codebase.get_file("FiLe3.BiN", ignore_case=True) == file3 + + # Test get_file with ignore_case=False (default) + assert codebase.get_file("FILE1.PY", ignore_case=False, optional=True) is None + assert codebase.get_file("FiLe1.Py", ignore_case=False, optional=True) is None + assert codebase.get_file("FILE2.PY", ignore_case=False, optional=True) is None + assert codebase.get_file("FiLe2.Py", ignore_case=False, optional=True) is None + assert codebase.get_file("FILE3.BIN", ignore_case=False, optional=True) is None + assert codebase.get_file("FiLe3.BiN", ignore_case=False, optional=True) is None + + +def test_minified_file(tmpdir) -> None: + with get_codebase_session( + tmpdir=tmpdir, + files={ + "file1.min.js": "console.log(123)", + "file2.js": open(f"{os.path.dirname(__file__)}/example.min.js").read(), + }, + programming_language=ProgrammingLanguage.TYPESCRIPT, + config=Config, + ) as codebase: + # This should match the `*.min.js` pattern + file1 = codebase.ctx.get_file("file1.min.js") + assert file1 is None + + # This should match the maximum line length threshold + file2 = codebase.ctx.get_file("file2.js") + assert file2 is None diff --git a/uv.lock b/uv.lock index 8e0364f57..b5521effa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.12, <3.14" resolution-markers = [ "python_full_version >= '3.12.4'", @@ -533,6 +532,7 @@ source = { editable = "." } dependencies = [ { name = "astor" }, { name = "click" }, + { name = "codegen-sdk-pink" }, { name = "codeowners" }, { name = "colorlog" }, { name = "dataclasses-json" }, @@ -662,6 +662,7 @@ requires-dist = [ { name = "astor", specifier = ">=0.8.1,<1.0.0" }, { name = "attrs", marker = "extra == 'lsp'", specifier = ">=25.1.0" }, { name = "click", specifier = ">=8.1.7" }, + { name = "codegen-sdk-pink", specifier = ">=0.1.0" }, { name = "codeowners", specifier = ">=0.6.0,<1.0.0" }, { name = "colorlog", specifier = ">=6.9.0" }, { name = "dataclasses-json", specifier = ">=0.6.4,<1.0.0" }, @@ -740,7 +741,6 @@ requires-dist = [ { name = "wrapt", specifier = ">=1.16.0,<2.0.0" }, { name = "xmltodict", specifier = ">=0.13.0,<1.0.0" }, ] -provides-extras = ["lsp", "types"] [package.metadata.requires-dev] dev = [ @@ -780,6 +780,21 @@ dev = [ { name = "uv", specifier = ">=0.4.25" }, ] +[[package]] +name = "codegen-sdk-pink" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/da/0e968f5bd8d839ec30b58b681ba30781d5eb1b33a95d771e4b31f3a7cf08/codegen_sdk_pink-0.1.0.tar.gz", hash = "sha256:3be5c2caf47f40ec541cdd04558d8ddfb816ede7d7334e4a62ab3f6130f86afb", size = 322299 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/4c/6321af0699207ab63b750e82589f2c4d8726956da9413e30a42c7ea59641/codegen_sdk_pink-0.1.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:03f71cd48cd7547faf8233b90f01f4c41b750b4195a83a6a1b6427bee24a45a4", size = 5749136 }, + { url = "https://files.pythonhosted.org/packages/c2/d0/39b35e45ce5683dace3e4b8c44e51a6471177708e5b3285fc1d764270ba1/codegen_sdk_pink-0.1.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:c4872286a1328ec546798268ab9ff3bf368c223178fecf45903cf0c667290471", size = 5807261 }, + { url = "https://files.pythonhosted.org/packages/db/19/5aff61ba06d877f385b206a8da88c87c77f6b7cd68f0aec7b8b16813e1a9/codegen_sdk_pink-0.1.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:64943be3bed917d506ece1e0b5492effaa500712c5109a3937266d440ee8bb53", size = 6387801 }, + { url = "https://files.pythonhosted.org/packages/5e/e4/6a8f7b12b20ab4cd61b833f32bbc1f7c8c86ca7332364f01f08881a4a5e2/codegen_sdk_pink-0.1.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:345deecefa2de455dcf1fb2bdf5ad2e71e74476b4212b1bd51f57e6904c1d7e9", size = 6231083 }, + { url = "https://files.pythonhosted.org/packages/0d/c3/b0f7106308e278b6774275c891bb82c08e04c41f1e9abf6bdf56757cc123/codegen_sdk_pink-0.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7c5bcf0ad41644ac980590a37178f231ba275a75ce946dcfc31fa39330c098da", size = 6543302 }, + { url = "https://files.pythonhosted.org/packages/e0/42/fedf5eec26a06d83de5cfb39fc7072261b72311b70d5fbbd4a75deec2457/codegen_sdk_pink-0.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b3ee15469ad58d0222dfa0ba5950cd0eb7b8b7c607912d1845950096ddcb7aad", size = 6682410 }, + { url = "https://files.pythonhosted.org/packages/38/fc/b1479140f579bcd6bdc090e71033484fcfd3bbc76aa779906a322cb33834/codegen_sdk_pink-0.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:10b9b00070b5561df80dd269524f106e44e222d1ab9a93f6cf6ca3565c0aa0f9", size = 4305666 }, +] + [[package]] name = "codeowners" version = "0.7.0" @@ -939,7 +954,7 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.28.2" +version = "0.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -952,9 +967,9 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/5f/74fac9f7262e7763eaf56bbcd64c31f712f68135f2c758bc02d15876c543/datamodel_code_generator-0.28.2.tar.gz", hash = "sha256:5f16fe4d6acee79c1366f9ee68016eeec544fc0a2fec25ce47d35f7b7767e0fe", size = 435017 } +sdist = { url = "https://files.pythonhosted.org/packages/db/12/3b30fb3ad74a5af84ac4efae538220a062eaed9effbffea99a7ffc061045/datamodel_code_generator-0.28.3.tar.gz", hash = "sha256:89c8163bbac954289ec0000d2ef8fad11aabb4652471305749c7fd06a9bd1651", size = 435150 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/a0/5ce4d9495355507dfb6093192d1762f745c3e824be6377fc3df8539f06dc/datamodel_code_generator-0.28.2-py3-none-any.whl", hash = "sha256:a2c425386c3f836c618ae276be57e460df323ac78f911b1b12d927ddffd70e73", size = 115645 }, + { url = "https://files.pythonhosted.org/packages/c2/aa/eea1e5ad2966f1944e1f5bf202df968531a207c811fa3256819e8b6ad480/datamodel_code_generator-0.28.3-py3-none-any.whl", hash = "sha256:697e8ac42f29b544e608dbad977cccdb8adb6c3f6b965f609d987abd15c36280", size = 115640 }, ] [[package]] @@ -2397,7 +2412,7 @@ wheels = [ [[package]] name = "modal" -version = "0.73.91" +version = "0.73.92" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2415,9 +2430,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/b6/c44d67b8468f86e2cad6c6f61afdc4021c97adabcc826d465eb45bd1b139/modal-0.73.91.tar.gz", hash = "sha256:77d8c556f9be37298e042ea0ea1602f5de3261ef1e3c4135f63a6f758be9c06d", size = 469844 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/22/edbde41a3ff6c9e43ed62545d3c1b126d26d72b67581e8b9d74354bfdaf6/modal-0.73.92.tar.gz", hash = "sha256:9fb8d6fa1c42c117e7c911a7b5e14afb07ff0a4005baa323cc2303008b96a2d4", size = 469815 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/a9/8bf787c9dde7e0ae5d5c33ba9a75e5b67aadd90fd6046441c88024f651c7/modal-0.73.91-py3-none-any.whl", hash = "sha256:5d47a250dc8af992934359ffe9ee7c0cb1a1b7a732f47651750a982ed6b9a214", size = 536057 }, + { url = "https://files.pythonhosted.org/packages/d8/41/cfebba4e449943fd9ffb7f33e9a3afc54d6f0fb135820433a7abf109b884/modal-0.73.92-py3-none-any.whl", hash = "sha256:5ab5188c2b4fb771563b315dbe0bcecf85b2dbe675f1fb1371e93122648c965d", size = 536055 }, ] [[package]] @@ -2549,11 +2564,11 @@ wheels = [ [[package]] name = "narwhals" -version = "1.29.1" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/17/7d35094da0820ae941d8ce51842f253da36c6f95360ea0afabfc18bc02c6/narwhals-1.29.1.tar.gz", hash = "sha256:c408acf09e90c116f247cf34f24a3a89d147e3e235b1d3c708cfd1960baf320a", size = 251464 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/98/be6d35e8869ab9403fa25dc3458e7af6ce36dac2873c74c7274a59b21958/narwhals-1.30.0.tar.gz", hash = "sha256:0c50cc67a5404da501302882838ec17dce51703d22cd8ad89162d6f60ea0bb19", size = 253461 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/22/380df533b08a57bc9013bb5714f33c571e1447828d83213a66adaefc0a04/narwhals-1.29.1-py3-none-any.whl", hash = "sha256:2f68cfbb2562672c4dfa54f158ed8c2828e9920ef784981cd9114e419c444216", size = 308220 }, + { url = "https://files.pythonhosted.org/packages/e4/97/bde1e4cf1e0fe0d4c70f750b57d152c0ecb04bb35de7aa7950a5756a71d6/narwhals-1.30.0-py3-none-any.whl", hash = "sha256:443aa0a1abfae89bc65a6b888a7e310a03d1818bfb2ccd61c150199a5f954c17", size = 313611 }, ] [[package]]