diff --git a/.fernignore b/.fernignore index 761658cd..76ebbbda 100644 --- a/.fernignore +++ b/.fernignore @@ -5,6 +5,7 @@ src/humanloop/evals src/humanloop/prompt_utils.py +src/humanloop/path_utils.py src/humanloop/client.py src/humanloop/overload.py src/humanloop/context.py diff --git a/poetry.lock b/poetry.lock index 016c7485..acbc4633 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -6,6 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -17,6 +18,7 @@ version = "0.51.0" description = "The official Python library for the anthropic API" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "anthropic-0.51.0-py3-none-any.whl", hash = "sha256:b8b47d482c9aa1f81b923555cebb687c2730309a20d01be554730c8302e0f62a"}, {file = "anthropic-0.51.0.tar.gz", hash = "sha256:6f824451277992af079554430d5b2c8ff5bc059cc2c968cdc3f06824437da201"}, @@ -41,6 +43,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -54,7 +57,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -63,18 +66,19 @@ version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "certifi" @@ -82,6 +86,7 @@ version = "2025.4.26" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, @@ -93,6 +98,7 @@ version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -194,6 +200,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -208,6 +215,7 @@ version = "5.15.0" description = "" optional = false python-versions = "<4.0,>=3.9" +groups = ["dev"] files = [ {file = "cohere-5.15.0-py3-none-any.whl", hash = "sha256:22ff867c2a6f2fc2b585360c6072f584f11f275ef6d9242bac24e0fa2df1dfb5"}, {file = "cohere-5.15.0.tar.gz", hash = "sha256:e802d4718ddb0bb655654382ebbce002756a3800faac30296cde7f1bdc6ff2cc"}, @@ -230,10 +238,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "deepdiff" @@ -241,6 +251,7 @@ version = "8.5.0" description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "deepdiff-8.5.0-py3-none-any.whl", hash = "sha256:d4599db637f36a1c285f5fdfc2cd8d38bde8d8be8636b65ab5e425b67c54df26"}, {file = "deepdiff-8.5.0.tar.gz", hash = "sha256:a4dd3529fa8d4cd5b9cbb6e3ea9c95997eaa919ba37dac3966c1b8f872dc1cd1"}, @@ -252,7 +263,7 @@ orderly-set = ">=5.4.1,<6" [package.extras] cli = ["click (>=8.1.0,<8.2.0)", "pyyaml (>=6.0.0,<6.1.0)"] coverage = ["coverage (>=7.6.0,<7.7.0)"] -dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0)", "numpy (>=2.2.0,<2.3.0)", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)"] +dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0) ; python_version < \"3.10\"", "numpy (>=2.2.0,<2.3.0) ; python_version >= \"3.10\"", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)"] docs = ["Sphinx (>=6.2.0,<6.3.0)", "sphinx-sitemap (>=2.6.0,<2.7.0)", "sphinxemoji (>=0.3.0,<0.4.0)"] optimize = ["orjson"] static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)", "pydantic (>=2.10.0,<2.11.0)"] @@ -264,6 +275,7 @@ version = "1.2.18" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["main"] files = [ {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, @@ -273,7 +285,7 @@ files = [ wrapt = ">=1.10,<2" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] [[package]] name = "distro" @@ -281,6 +293,7 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -292,6 +305,8 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -309,6 +324,7 @@ version = "2.1.1" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, @@ -323,6 +339,7 @@ version = "1.10.0" description = "Fast read/write of AVRO files" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "fastavro-1.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1a9fe0672d2caf0fe54e3be659b13de3cad25a267f2073d6f4b9f8862acc31eb"}, {file = "fastavro-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86dd0410770e0c99363788f0584523709d85e57bb457372ec5c285a482c17fe6"}, @@ -369,6 +386,7 @@ version = "3.18.0" description = "A platform independent file lock." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, @@ -377,7 +395,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "fsspec" @@ -385,6 +403,7 @@ version = "2025.3.2" description = "File-system specification" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "fsspec-2025.3.2-py3-none-any.whl", hash = "sha256:2daf8dc3d1dfa65b6aa37748d112773a7a08416f6c70d96b264c96476ecaf711"}, {file = "fsspec-2025.3.2.tar.gz", hash = "sha256:e52c77ef398680bbd6a98c0e628fbc469491282981209907bbc8aea76a04fdc6"}, @@ -424,6 +443,7 @@ version = "0.24.0" description = "The official Python library for the groq API" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "groq-0.24.0-py3-none-any.whl", hash = "sha256:0020e6b0b2b267263c9eb7c318deef13c12f399c6525734200b11d777b00088e"}, {file = "groq-0.24.0.tar.gz", hash = "sha256:e821559de8a77fb81d2585b3faec80ff923d6d64fd52339b33f6c94997d6f7f5"}, @@ -443,6 +463,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -454,6 +475,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -475,6 +497,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -487,7 +510,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -499,6 +522,7 @@ version = "0.4.0" description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, @@ -510,6 +534,7 @@ version = "0.31.2" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" +groups = ["main", "dev"] files = [ {file = "huggingface_hub-0.31.2-py3-none-any.whl", hash = "sha256:8138cd52aa2326b4429bb00a4a1ba8538346b7b8a808cdce30acb6f1f1bdaeec"}, {file = "huggingface_hub-0.31.2.tar.gz", hash = "sha256:7053561376ed7f6ffdaecf09cc54d70dc784ac6315fa4bb9b93e19662b029675"}, @@ -545,6 +570,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -559,6 +585,7 @@ version = "8.6.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, @@ -568,12 +595,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -582,6 +609,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -593,6 +621,7 @@ version = "0.9.0" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad"}, {file = "jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea"}, @@ -678,6 +707,7 @@ version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, @@ -699,6 +729,7 @@ version = "2025.4.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, @@ -713,6 +744,7 @@ version = "5.1.0" description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "mmh3-5.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eaf4ac5c6ee18ca9232238364d7f2a213278ae5ca97897cafaa123fcc7bb8bec"}, {file = "mmh3-5.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:48f9aa8ccb9ad1d577a16104834ac44ff640d8de8c0caed09a2300df7ce8460a"}, @@ -811,6 +843,7 @@ version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, @@ -864,6 +897,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -875,6 +909,7 @@ version = "1.26.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, @@ -920,6 +955,7 @@ version = "1.78.1" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "openai-1.78.1-py3-none-any.whl", hash = "sha256:7368bf147ca499804cc408fe68cdb6866a060f38dec961bbc97b04f9d917907e"}, {file = "openai-1.78.1.tar.gz", hash = "sha256:8b26b364531b100df1b961d03560042e5f5be11301d7d49a6cd1a2b9af824dca"}, @@ -946,6 +982,7 @@ version = "1.33.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "opentelemetry_api-1.33.0-py3-none-any.whl", hash = "sha256:158df154f628e6615b65fdf6e59f99afabea7213e72c5809dd4adf06c0d997cd"}, {file = "opentelemetry_api-1.33.0.tar.gz", hash = "sha256:cc4380fd2e6da7dcb52a828ea81844ed1f4f2eb638ca3c816775109d93d58ced"}, @@ -961,6 +998,7 @@ version = "0.54b0" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation-0.54b0-py3-none-any.whl", hash = "sha256:1a502238f8af65625ad48800d268d467653e319d959e1732d3b3248916d21327"}, {file = "opentelemetry_instrumentation-0.54b0.tar.gz", hash = "sha256:2949d0bbf2316eb5d928a5ef610d0a8a2c261ba80167d878abf6016e1c4ae7bb"}, @@ -978,6 +1016,7 @@ version = "0.40.5" description = "OpenTelemetry Anthropic instrumentation" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_anthropic-0.40.5-py3-none-any.whl", hash = "sha256:d3b203b0ee8ee06149711d7acfa0085ad44f1841d709fd4d639934b9d8aa87b4"}, {file = "opentelemetry_instrumentation_anthropic-0.40.5.tar.gz", hash = "sha256:5a7e9b3852cd8cfc43e50450d83d40b1a15d12b295fb18d2c814576072fcc23f"}, @@ -995,6 +1034,7 @@ version = "0.40.5" description = "OpenTelemetry Bedrock instrumentation" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_bedrock-0.40.5-py3-none-any.whl", hash = "sha256:5f3cf77c03dfa4ab04fc202bf91a98688053ecb7d1bc64b2eaf42c866fb77c69"}, {file = "opentelemetry_instrumentation_bedrock-0.40.5.tar.gz", hash = "sha256:0ab690501101a67cff3c4037fa6bdfeb65d25d9ec365e97880ebe118a0a6dd30"}, @@ -1014,6 +1054,7 @@ version = "0.40.5" description = "OpenTelemetry Cohere instrumentation" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_cohere-0.40.5-py3-none-any.whl", hash = "sha256:ff73b4eed87f1d79e737351aabec85fde3aaa2f793c87e508c20498ccbea3d48"}, {file = "opentelemetry_instrumentation_cohere-0.40.5.tar.gz", hash = "sha256:98332b9bea8b9c84222682a57ebb431e693ab1d548a4a3f4301a9b1dfc3a6cbc"}, @@ -1031,6 +1072,7 @@ version = "0.40.5" description = "OpenTelemetry Groq instrumentation" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_groq-0.40.5-py3-none-any.whl", hash = "sha256:dec20e7f50f648068b2cfb730da0e2297dbdc9a93840cbbc595f652cd0e9e94b"}, {file = "opentelemetry_instrumentation_groq-0.40.5.tar.gz", hash = "sha256:036bda3c9317a3d34c7538479864d215e0f4e147b5fe475be4f1cd4402b2ae30"}, @@ -1048,6 +1090,7 @@ version = "0.40.5" description = "OpenTelemetry OpenAI instrumentation" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_openai-0.40.5-py3-none-any.whl", hash = "sha256:533cbfc00a6d629998c5adb49e37a2165559a5d26bb6bb6a61f768bf23e96cf9"}, {file = "opentelemetry_instrumentation_openai-0.40.5.tar.gz", hash = "sha256:691d9e7bca55b5a21538c86127ee5af05b033385212aaeb64eab2dd383cb815b"}, @@ -1066,6 +1109,7 @@ version = "0.40.5" description = "OpenTelemetry Replicate instrumentation" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_replicate-0.40.5-py3-none-any.whl", hash = "sha256:948ecea48de37639433a64cc36bbb5f61f1de24122cafcf3101d5c4c113b7a82"}, {file = "opentelemetry_instrumentation_replicate-0.40.5.tar.gz", hash = "sha256:d5d70375619ed286c80f25631ac1ab1cbe58146dcf90efa92203c7c93b8d5b6c"}, @@ -1083,6 +1127,7 @@ version = "1.33.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "opentelemetry_proto-1.33.0-py3-none-any.whl", hash = "sha256:84a1d7daacac4aa0f24a5b1190a3e0619011dbff56f945fc2b6fc0a18f48b942"}, {file = "opentelemetry_proto-1.33.0.tar.gz", hash = "sha256:ec5aa35486c990207ead2512a8d616d1b324928562c91dbc7e0cb9aa48c60b7b"}, @@ -1097,6 +1142,7 @@ version = "1.33.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "opentelemetry_sdk-1.33.0-py3-none-any.whl", hash = "sha256:bed376b6d37fbf00688bb65edfee817dd01d48b8559212831437529a6066049a"}, {file = "opentelemetry_sdk-1.33.0.tar.gz", hash = "sha256:a7fc56d1e07b218fcc316b24d21b59d3f1967b2ca22c217b05da3a26b797cc68"}, @@ -1113,6 +1159,7 @@ version = "0.54b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "opentelemetry_semantic_conventions-0.54b0-py3-none-any.whl", hash = "sha256:fad7c1cf8908fd449eb5cf9fbbeefb301acf4bc995101f85277899cec125d823"}, {file = "opentelemetry_semantic_conventions-0.54b0.tar.gz", hash = "sha256:467b739977bdcb079af1af69f73632535cdb51099d5e3c5709a35d10fe02a9c9"}, @@ -1128,6 +1175,7 @@ version = "0.4.8" description = "OpenTelemetry Semantic Conventions Extension for Large Language Models" optional = false python-versions = "<4,>=3.9" +groups = ["main"] files = [ {file = "opentelemetry_semantic_conventions_ai-0.4.8-py3-none-any.whl", hash = "sha256:02f7d5876a21e4376d5f4e7cd1157677e3e186f60bb3b6375a1205518a5f0908"}, {file = "opentelemetry_semantic_conventions_ai-0.4.8.tar.gz", hash = "sha256:b4663403315aa08e83c4651c13dbc1fbe3e518229b4df87ca32e24895dd04007"}, @@ -1139,6 +1187,7 @@ version = "5.4.1" description = "Orderly set" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "orderly_set-5.4.1-py3-none-any.whl", hash = "sha256:b5e21d21680bd9ef456885db800c5cb4f76a03879880c0175e1b077fb166fd83"}, {file = "orderly_set-5.4.1.tar.gz", hash = "sha256:a1fb5a4fdc5e234e9e8d8e5c1bbdbc4540f4dfe50d12bf17c8bc5dbf1c9c878d"}, @@ -1150,6 +1199,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1161,6 +1211,7 @@ version = "2.2.3" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, @@ -1247,6 +1298,7 @@ version = "1.20.2" description = "parse() is the opposite of format()" optional = false python-versions = "*" +groups = ["main", "dev"] files = [ {file = "parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558"}, {file = "parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce"}, @@ -1258,6 +1310,7 @@ version = "0.6.4" description = "Simplifies to build parse types based on the parse module" optional = false python-versions = "!=3.0.*,!=3.1.*,>=2.7" +groups = ["dev"] files = [ {file = "parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c"}, {file = "parse_type-0.6.4.tar.gz", hash = "sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6"}, @@ -1268,9 +1321,9 @@ parse = {version = ">=1.18.0", markers = "python_version >= \"3.0\""} six = ">=1.15" [package.extras] -develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0)", "pytest (>=5.0)", "pytest-cov", "pytest-html (>=1.19.0)", "ruff", "setuptools", "setuptools-scm", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0)", "virtualenv (>=20.0.0)", "wheel"] +develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-cov", "pytest-html (>=1.19.0)", "ruff ; python_version >= \"3.7\"", "setuptools", "setuptools-scm", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0) ; python_version <= \"3.6\"", "virtualenv (>=20.0.0) ; python_version > \"3.6\"", "wheel"] docs = ["Sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6.0)"] -testing = ["pytest (<5.0)", "pytest (>=5.0)", "pytest-html (>=1.19.0)"] +testing = ["pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"] [[package]] name = "pluggy" @@ -1278,6 +1331,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1293,6 +1347,7 @@ version = "5.29.4" description = "" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "protobuf-5.29.4-cp310-abi3-win32.whl", hash = "sha256:13eb236f8eb9ec34e63fc8b1d6efd2777d062fa6aaa68268fb67cf77f6839ad7"}, {file = "protobuf-5.29.4-cp310-abi3-win_amd64.whl", hash = "sha256:bcefcdf3976233f8a502d265eb65ea740c989bacc6c30a58290ed0e519eb4b8d"}, @@ -1313,6 +1368,7 @@ version = "19.0.1" description = "Python library for Apache Arrow" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69"}, {file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec"}, @@ -1367,6 +1423,7 @@ version = "2.11.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb"}, {file = "pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d"}, @@ -1380,7 +1437,7 @@ typing-inspection = ">=0.4.0" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -1388,6 +1445,7 @@ version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, @@ -1499,6 +1557,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -1521,6 +1580,7 @@ version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, @@ -1539,6 +1599,7 @@ version = "1.7.0" description = "Adds the ability to retry flaky tests in CI environments" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest_retry-1.7.0-py3-none-any.whl", hash = "sha256:a2dac85b79a4e2375943f1429479c65beb6c69553e7dae6b8332be47a60954f4"}, {file = "pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f"}, @@ -1556,6 +1617,7 @@ version = "3.6.1" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, @@ -1576,6 +1638,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1590,6 +1653,7 @@ version = "1.1.0" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, @@ -1604,6 +1668,7 @@ version = "2025.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, @@ -1615,6 +1680,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1677,6 +1743,7 @@ version = "0.36.2" description = "JSON Referencing + Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, @@ -1693,6 +1760,7 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -1796,6 +1864,7 @@ version = "1.0.6" description = "Python client for Replicate" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "replicate-1.0.6-py3-none-any.whl", hash = "sha256:d544f837dc7e9dc3b3c1df60a145c7d6f362d6719b719793a44a4be28837103d"}, {file = "replicate-1.0.6.tar.gz", hash = "sha256:b8a0f1649ed4146c3d624e22a418b8c6decce9346cffc110c90fde5995c46e60"}, @@ -1813,6 +1882,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1834,6 +1904,7 @@ version = "0.25.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "rpds_py-0.25.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c146a24a8f0dc4a7846fb4640b88b3a68986585b8ce8397af15e66b7c5817439"}, {file = "rpds_py-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:77814c7a4e1dc43fba73aeb4c1ef0fe37d901f3aa869a4823de5ea843a283fd0"}, @@ -1957,6 +2028,7 @@ version = "0.11.5" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b"}, {file = "ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077"}, @@ -1984,6 +2056,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1995,6 +2068,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2006,6 +2080,7 @@ version = "0.9.0" description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "tiktoken-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:586c16358138b96ea804c034b8acf3f5d3f0258bd2bc3b0227af4af5d622e382"}, {file = "tiktoken-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9c59ccc528c6c5dd51820b3474402f69d9a9e1d656226848ad68a8d5b2e5108"}, @@ -2053,6 +2128,7 @@ version = "0.21.1" description = "" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41"}, {file = "tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3"}, @@ -2085,6 +2161,8 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -2126,6 +2204,7 @@ version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -2147,6 +2226,7 @@ version = "4.23.0.20241208" description = "Typing stubs for jsonschema" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types_jsonschema-4.23.0.20241208-py3-none-any.whl", hash = "sha256:87934bd9231c99d8eff94cacfc06ba668f7973577a9bd9e1f9de957c5737313e"}, {file = "types_jsonschema-4.23.0.20241208.tar.gz", hash = "sha256:e8b15ad01f290ecf6aea53f93fbdf7d4730e4600313e89e8a7f95622f7e87b7c"}, @@ -2161,6 +2241,7 @@ version = "5.29.1.20250403" description = "Typing stubs for protobuf" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "types_protobuf-5.29.1.20250403-py3-none-any.whl", hash = "sha256:c71de04106a2d54e5b2173d0a422058fae0ef2d058d70cf369fb797bf61ffa59"}, {file = "types_protobuf-5.29.1.20250403.tar.gz", hash = "sha256:7ff44f15022119c9d7558ce16e78b2d485bf7040b4fadced4dd069bb5faf77a2"}, @@ -2172,6 +2253,7 @@ version = "2.9.0.20241206" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, @@ -2183,6 +2265,7 @@ version = "2.32.0.20250515" description = "Typing stubs for requests" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "types_requests-2.32.0.20250515-py3-none-any.whl", hash = "sha256:f8eba93b3a892beee32643ff836993f15a785816acca21ea0ffa006f05ef0fb2"}, {file = "types_requests-2.32.0.20250515.tar.gz", hash = "sha256:09c8b63c11318cb2460813871aaa48b671002e59fda67ca909e9883777787581"}, @@ -2197,6 +2280,7 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, @@ -2208,6 +2292,7 @@ version = "0.4.0" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, @@ -2222,6 +2307,7 @@ version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["dev"] files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, @@ -2233,13 +2319,14 @@ version = "2.4.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2250,6 +2337,7 @@ version = "1.17.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, @@ -2338,20 +2426,21 @@ version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.9,<4" content-hash = "b13d162d3fbca536d5eabbb7baec8155cc8eaeffc3c26227397a572bddde5385" diff --git a/src/humanloop/cli/__main__.py b/src/humanloop/cli/__main__.py index 3ab53cfb..1df37cba 100644 --- a/src/humanloop/cli/__main__.py +++ b/src/humanloop/cli/__main__.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from humanloop import Humanloop -from humanloop.sync.sync_client import SyncClient +from humanloop.sync.file_syncer import FileSyncer # Set up logging logger = logging.getLogger(__name__) @@ -154,6 +154,7 @@ def cli(): # Does nothing because used as a group for other subcommands (pull, "-p", help="Path in the Humanloop workspace to pull from (file or directory). You can pull an entire directory (e.g. 'my/directory') " "or a specific file (e.g. 'my/directory/my_prompt.prompt'). When pulling a directory, all files within that directory and its subdirectories will be included. " + "Paths should not contain leading or trailing slashes. " "If not specified, pulls from the root of the remote workspace.", default=None, ) @@ -218,7 +219,12 @@ def pull( Currently only supports syncing Prompt and Agent files. Other file types will be skipped.""" client = get_client(api_key, env_file, base_url) - sync_client = SyncClient( + # Although pull() is available on the Humanloop client, we instantiate FileSyncer separately to control its log level. + # This allows CLI users to toggle between detailed logging (--verbose) and minimal output without affecting the + # main Humanloop client logger. The FileSyncer uses its own logger namespace (humanloop.sdk.file_syncer), making this + # modification isolated from the client's OpenTelemetry setup. This client instance is short-lived and only + # exists for the duration of the CLI command execution. + file_syncer = FileSyncer( client, base_dir=local_files_directory, log_level=logging.DEBUG if verbose else logging.WARNING ) @@ -227,7 +233,7 @@ def pull( click.echo(click.style(f"Environment: {environment or '(default)'}", fg=INFO_COLOR)) start_time = time.time() - successful_files, failed_files = sync_client.pull(path, environment) + successful_files, failed_files = file_syncer.pull(path, environment) duration_ms = int((time.time() - start_time) * 1000) # Determine if the operation was successful based on failed_files diff --git a/src/humanloop/client.py b/src/humanloop/client.py index aeb11037..805b15d9 100644 --- a/src/humanloop/client.py +++ b/src/humanloop/client.py @@ -28,7 +28,7 @@ from humanloop.overload import overload_client from humanloop.prompt_utils import populate_template from humanloop.prompts.client import PromptsClient -from humanloop.sync.sync_client import DEFAULT_CACHE_SIZE, SyncClient +from humanloop.sync.file_syncer import DEFAULT_CACHE_SIZE, FileSyncer logger = logging.getLogger("humanloop.sdk") @@ -158,7 +158,7 @@ def __init__( ) # Check if cache_size is non-default but use_local_files is False - self._sync_client = SyncClient(client=self, base_dir=local_files_directory, cache_size=cache_size) + self._file_syncer = FileSyncer(client=self, base_dir=local_files_directory, cache_size=cache_size) eval_client = ExtendedEvalsClient(client_wrapper=self._client_wrapper) eval_client.client = self self.evaluations = eval_client @@ -168,10 +168,10 @@ def __init__( # and the @flow decorator providing the trace_id # Additionally, call and log methods are overloaded in the prompts and agents client to support the use of local files self.prompts = overload_client( - client=self.prompts, sync_client=self._sync_client, use_local_files=self.use_local_files + client=self.prompts, file_syncer=self._file_syncer, use_local_files=self.use_local_files ) self.agents = overload_client( - client=self.agents, sync_client=self._sync_client, use_local_files=self.use_local_files + client=self.agents, file_syncer=self._file_syncer, use_local_files=self.use_local_files ) self.flows = overload_client(client=self.flows) self.tools = overload_client(client=self.tools) @@ -439,7 +439,7 @@ def pull(self, path: Optional[str] = None, environment: Optional[str] = None) -> or filesystem issues) :raises HumanloopRuntimeError: If there's an error communicating with the API """ - return self._sync_client.pull(environment=environment, path=path) + return self._file_syncer.pull(environment=environment, path=path) class AsyncHumanloop(AsyncBaseHumanloop): diff --git a/src/humanloop/overload.py b/src/humanloop/overload.py index b4325716..f05a6a5b 100644 --- a/src/humanloop/overload.py +++ b/src/humanloop/overload.py @@ -1,6 +1,7 @@ import inspect import logging import types +from pathlib import Path from typing import Any, Callable, Dict, Optional, TypeVar, Union from humanloop.agents.client import AgentsClient @@ -14,7 +15,7 @@ from humanloop.evaluators.client import EvaluatorsClient from humanloop.flows.client import FlowsClient from humanloop.prompts.client import PromptsClient -from humanloop.sync.sync_client import SyncClient +from humanloop.sync.file_syncer import FileSyncer from humanloop.tools.client import ToolsClient from humanloop.types import FileType from humanloop.types.agent_call_response import AgentCallResponse @@ -65,7 +66,7 @@ def _get_file_type_from_client( def _handle_tracing_context(kwargs: Dict[str, Any], client: T) -> Dict[str, Any]: """Handle tracing context for both log and call methods.""" - trace_id = get_trace_id() + trace_id = get_trace_id() if trace_id is not None: if "flow" in str(type(client).__name__).lower(): context = get_decorator_context() @@ -91,45 +92,81 @@ def _handle_tracing_context(kwargs: Dict[str, Any], client: T) -> Dict[str, Any] def _handle_local_files( kwargs: Dict[str, Any], client: T, - sync_client: Optional[SyncClient], - use_local_files: bool, + file_syncer: FileSyncer, ) -> Dict[str, Any]: - """Handle local file loading if enabled.""" - if not use_local_files or "path" not in kwargs or sync_client is None: - return kwargs + """Load prompt/agent file content from local filesystem into API request. + + Retrieves the file content at the specified path and adds it to kwargs + under the appropriate field ('prompt' or 'agent'), allowing local files + to be used in API calls instead of fetching from Humanloop API. + + Args: + kwargs: API call arguments + client: Client instance making the call + file_syncer: FileSyncer handling local file operations + + Returns: + Updated kwargs with file content in prompt/agent field + Raises: + HumanloopRuntimeError: On validation or file loading failures. + For example, an invalid path format (absolute paths, leading/trailing slashes, etc.) or a file not being found. + """ if "id" in kwargs: raise HumanloopRuntimeError("Can only specify one of `id` or `path`") + path = kwargs["path"] + + # First check for path format issues (absolute paths or leading/trailing slashes) + normalized_path = path.strip("/") + if Path(path).is_absolute() or path != normalized_path: + raise HumanloopRuntimeError( + f"Path '{path}' format is invalid. " + f"Paths must follow the standard API format 'path/to/resource' without leading or trailing slashes. " + f"Please use '{normalized_path}' instead." + ) + + # Then check for file extensions + if file_syncer.is_file(path): + # Extract the path without extension to suggest correct format in the error message + path_without_extension = str(Path(path).with_suffix("")) + + # Always raise error when file extension is detected (based on the outer if condition) + raise HumanloopRuntimeError( + f"Path '{path}' includes a file extension which is not supported in API calls. " + f"When referencing files via the `path` parameter, use the path without extensions: '{path_without_extension}'. " + f"Note: File extensions are only used when pulling specific files via the CLI." + ) + # Check if version_id or environment is specified use_remote = any(["version_id" in kwargs, "environment" in kwargs]) - normalized_path = sync_client._normalize_path(kwargs["path"]) if use_remote: raise HumanloopRuntimeError( - f"Cannot use local file for `{normalized_path}` as version_id or environment was specified. " + f"Cannot use local file for `{path}` as version_id or environment was specified. " "Please either remove version_id/environment to use local files, or set use_local_files=False to use remote files." ) file_type = _get_file_type_from_client(client) - if file_type not in SyncClient.SERIALIZABLE_FILE_TYPES: - raise HumanloopRuntimeError(f"Local files are not supported for `{file_type}` files.") + if file_type not in FileSyncer.SERIALIZABLE_FILE_TYPES: + raise HumanloopRuntimeError(f"Local files are not supported for `{file_type.capitalize()}` files: '{path}'.") - # If file_type is already specified in kwargs, it means user provided a PromptKernelRequestParams object + # If file_type is already specified in kwargs (`prompt` or `agent`), it means user provided a Prompt- or AgentKernelRequestParams object + # In this case, we should prioritize the user-provided value over the local file content. if file_type in kwargs and not isinstance(kwargs[file_type], str): logger.warning( - f"Ignoring local file for `{normalized_path}` as {file_type} parameters were directly provided. " + f"Ignoring local file for `{path}` as {file_type} parameters were directly provided. " "Using provided parameters instead." ) return kwargs try: - file_content = sync_client.get_file_content(normalized_path, file_type) # type: ignore[arg-type] # file_type was checked above + file_content = file_syncer.get_file_content(path, file_type) # type: ignore[arg-type] # file_type was checked above kwargs[file_type] = file_content - except HumanloopRuntimeError as e: - raise HumanloopRuntimeError(f"Failed to use local file for `{normalized_path}`: {str(e)}") - return kwargs + return kwargs + except HumanloopRuntimeError as e: + raise HumanloopRuntimeError(f"Failed to use local file for `{path}`: {str(e)}") def _handle_evaluation_context(kwargs: Dict[str, Any]) -> tuple[Dict[str, Any], Optional[Callable[[str], None]]]: @@ -140,7 +177,7 @@ def _handle_evaluation_context(kwargs: Dict[str, Any]) -> tuple[Dict[str, Any], return kwargs, None -def _overload_log(self: T, sync_client: Optional[SyncClient], use_local_files: bool, **kwargs) -> LogResponseType: +def _overload_log(self: T, file_syncer: Optional[FileSyncer], use_local_files: bool, **kwargs) -> LogResponseType: try: # Special handling for flows - prevent direct log usage if type(self) is FlowsClient and get_trace_id() is not None: @@ -154,12 +191,20 @@ def _overload_log(self: T, sync_client: Optional[SyncClient], use_local_files: b kwargs = _handle_tracing_context(kwargs, self) - # Handle local files for Prompts and Agents clients - if _get_file_type_from_client(self) in ["prompt", "agent"]: - if sync_client is None: - logger.error("sync_client is None but client has log method and use_local_files=%s", use_local_files) - raise HumanloopRuntimeError("sync_client is required for clients that support local file operations") - kwargs = _handle_local_files(kwargs, self, sync_client, use_local_files) + # Handle loading files from local filesystem when using Prompt and Agent clients + # This enables users to define prompts/agents in local files rather than fetching from the Humanloop API + if use_local_files and _get_file_type_from_client(self) in FileSyncer.SERIALIZABLE_FILE_TYPES: + # Developer note: file_syncer should always be provided during SDK initialization when + # use_local_files=True. If we hit this error, there's likely an initialization issue + # in Humanloop.__init__ where the file_syncer wasn't properly created or passed to the + # overload_client function. + if file_syncer is None: + logger.error("file_syncer is None but client has log method and use_local_files=%s", use_local_files) + raise HumanloopRuntimeError( + "SDK initialization error: file_syncer is missing but required for local file operations. " + "This is likely a bug in the SDK initialization - please report this issue to the Humanloop team." + ) + kwargs = _handle_local_files(kwargs, self, file_syncer) kwargs, eval_callback = _handle_evaluation_context(kwargs) response = self._log(**kwargs) # type: ignore[union-attr] # Use stored original method @@ -174,10 +219,16 @@ def _overload_log(self: T, sync_client: Optional[SyncClient], use_local_files: b raise HumanloopRuntimeError from e -def _overload_call(self: T, sync_client: Optional[SyncClient], use_local_files: bool, **kwargs) -> CallResponseType: +def _overload_call(self: T, file_syncer: Optional[FileSyncer], use_local_files: bool, **kwargs) -> CallResponseType: try: kwargs = _handle_tracing_context(kwargs, self) - kwargs = _handle_local_files(kwargs, self, sync_client, use_local_files) + # If `use_local_files` flag is True, we should use local file content for `call` operations on Prompt and Agent clients. + if use_local_files and _get_file_type_from_client(self) in FileSyncer.SERIALIZABLE_FILE_TYPES: + # Same file_syncer requirement as in _overload_log - see developer note there + if file_syncer is None: + logger.error("file_syncer is None but client has call method and use_local_files=%s", use_local_files) + raise HumanloopRuntimeError("file_syncer is required for clients that support call operations") + kwargs = _handle_local_files(kwargs, self, file_syncer) return self._call(**kwargs) # type: ignore[union-attr] # Use stored original method except HumanloopRuntimeError: # Re-raise HumanloopRuntimeError without wrapping to preserve the message @@ -189,7 +240,7 @@ def _overload_call(self: T, sync_client: Optional[SyncClient], use_local_files: def overload_client( client: T, - sync_client: Optional[SyncClient] = None, + file_syncer: Optional[FileSyncer] = None, use_local_files: bool = False, ) -> T: """Overloads client methods to add tracing, local file handling, and evaluation context.""" @@ -198,25 +249,25 @@ def overload_client( # Store original method with type ignore client._log = client.log # type: ignore - # Create a closure to capture sync_client and use_local_files + # Create a closure to capture file_syncer and use_local_files def log_wrapper(self: T, **kwargs) -> LogResponseType: - return _overload_log(self, sync_client, use_local_files, **kwargs) + return _overload_log(self, file_syncer, use_local_files, **kwargs) # Replace the log method with type ignore client.log = types.MethodType(log_wrapper, client) # type: ignore # Overload call method for Prompt and Agent clients - if _get_file_type_from_client(client) in ["prompt", "agent"]: - if sync_client is None and use_local_files: - logger.error("sync_client is None but client has call method and use_local_files=%s", use_local_files) - raise HumanloopRuntimeError("sync_client is required for clients that support call operations") + if _get_file_type_from_client(client) in FileSyncer.SERIALIZABLE_FILE_TYPES: + if file_syncer is None and use_local_files: + logger.error("file_syncer is None but client has call method and use_local_files=%s", use_local_files) + raise HumanloopRuntimeError("file_syncer is required for clients that support call operations") if hasattr(client, "call") and not hasattr(client, "_call"): # Store original method with type ignore client._call = client.call # type: ignore - # Create a closure to capture sync_client and use_local_files + # Create a closure to capture file_syncer and use_local_files def call_wrapper(self: T, **kwargs) -> CallResponseType: - return _overload_call(self, sync_client, use_local_files, **kwargs) + return _overload_call(self, file_syncer, use_local_files, **kwargs) # Replace the call method with type ignore client.call = types.MethodType(call_wrapper, client) # type: ignore diff --git a/src/humanloop/path_utils.py b/src/humanloop/path_utils.py new file mode 100644 index 00000000..4bca576d --- /dev/null +++ b/src/humanloop/path_utils.py @@ -0,0 +1,54 @@ +from pathlib import Path + + +def normalize_path(path: str, strip_extension: bool = False) -> str: + """Normalize a path to the standard Humanloop API format. + + This function is primarily used when interacting with the Humanloop API to ensure paths + follow the standard format: 'path/to/resource' without leading/trailing slashes. + It's used when pulling files from Humanloop to local filesystem (see FileSyncer.pull) + + The function: + - Converts Windows backslashes to forward slashes + - Normalizes consecutive slashes + - Optionally strips file extensions (e.g. .prompt, .agent) + - Removes leading/trailing slashes to match API conventions + + Leading/trailing slashes are stripped because the Humanloop API expects paths in the + format 'path/to/resource' without them. This is consistent with how the API stores + and references files, and ensures paths work correctly in both API calls and local + filesystem operations. + + Args: + path: The path to normalize. Can be a Windows or Unix-style path. + strip_extension: If True, removes the file extension (e.g. .prompt, .agent) + + Returns: + Normalized path string in the format 'path/to/resource' + + Examples: + >>> normalize_path("path/to/file.prompt") + 'path/to/file.prompt' + >>> normalize_path("path/to/file.prompt", strip_extension=True) + 'path/to/file' + >>> normalize_path("\\windows\\style\\path.prompt") + 'windows/style/path.prompt' + >>> normalize_path("/leading/slash/path/") + 'leading/slash/path' + >>> normalize_path("multiple//slashes//path") + 'multiple/slashes/path' + """ + # Handle backslashes for Windows paths before passing to PurePosixPath + # This is needed because some backslash sequences are treated as escape chars + path = path.replace("\\", "/") + + # Use PurePosixPath to normalize the path (handles consecutive slashes) + path_obj = Path(path) + + # Strip extension if requested + if strip_extension: + path_obj = path_obj.with_suffix("") + + # Convert to string and remove any leading/trailing slashes + # We use the path as a string and not as_posix() since we've already normalized separators + return str(path_obj).strip("/") diff --git a/src/humanloop/sync/__init__.py b/src/humanloop/sync/__init__.py index 007659df..ec657f4b 100644 --- a/src/humanloop/sync/__init__.py +++ b/src/humanloop/sync/__init__.py @@ -1,3 +1,3 @@ -from humanloop.sync.sync_client import SyncClient +from humanloop.sync.file_syncer import FileSyncer -__all__ = ["SyncClient"] +__all__ = ["FileSyncer"] diff --git a/src/humanloop/sync/sync_client.py b/src/humanloop/sync/file_syncer.py similarity index 74% rename from src/humanloop/sync/sync_client.py rename to src/humanloop/sync/file_syncer.py index a3f7f504..29e26c48 100644 --- a/src/humanloop/sync/sync_client.py +++ b/src/humanloop/sync/file_syncer.py @@ -6,13 +6,18 @@ from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Tuple +from humanloop import path_utils from humanloop.error import HumanloopRuntimeError if TYPE_CHECKING: from humanloop.base_client import BaseHumanloop -# Set up logging -logger = logging.getLogger("humanloop.sdk.sync") +# Set up isolated logger for file sync operations +# This logger uses the "humanloop.sdk.file_syncer" namespace, separate from the main client's logger, +# allowing CLI commands and other consumers to control sync logging verbosity independently. +# This approach ensures that increasing verbosity for sync operations doesn't affect +# other components of the system. +logger = logging.getLogger("humanloop.sdk.file_syncer") logger.setLevel(logging.INFO) console_handler = logging.StreamHandler() formatter = logging.Formatter("%(message)s") @@ -57,16 +62,17 @@ def format_api_error(error: Exception) -> str: SerializableFileType = typing.Literal["prompt", "agent"] -class SyncClient: - """Client for managing synchronization between local filesystem and Humanloop. +class FileSyncer: + """Client for synchronizing Prompt and Agent files between Humanloop workspace and local filesystem. - This client provides file synchronization between Humanloop and the local filesystem, - with built-in caching for improved performance. The cache uses Python's LRU (Least - Recently Used) cache to automatically manage memory usage by removing least recently - accessed files when the cache is full. + This client enables a local development workflow by: + 1. Pulling files from Humanloop workspace to local filesystem + 2. Maintaining the same directory structure locally as in Humanloop + 3. Storing files in human-readable, version-control friendly formats (.prompt and .agent) + 4. Supporting local file access in the SDK when configured with use_local_files=True - The cache is automatically updated when files are pulled or saved, and can be - manually cleared using the clear_cache() method. + Files maintain their relative paths from the Humanloop workspace (with appropriate extensions added), + allowing for seamless reference between local and remote environments using the same path identifiers. """ # File types that can be serialized to/from the filesystem @@ -79,18 +85,25 @@ def __init__( cache_size: int = DEFAULT_CACHE_SIZE, log_level: int = logging.WARNING, ): - """ + """Initialize the FileSyncer. + Parameters ---------- client: Humanloop client instance base_dir: Base directory for synced files (default: "humanloop") cache_size: Maximum number of files to cache (default: DEFAULT_CACHE_SIZE) log_level: Log level for logging (default: WARNING) + Note: The FileSyncer uses an isolated logger (humanloop.sdk.file_syncer) separate from + the main Humanloop client logger. This allows controlling the verbosity of + sync operations independently from other client operations, which is particularly + useful in CLI contexts where users may want detailed sync logs without affecting + the main client's log level. """ self.client = client self.base_dir = Path(base_dir) self._cache_size = cache_size + # Set log level for the isolated FileSyncer logger logger.setLevel(log_level) # Create a new cached version of get_file_content with the specified cache size @@ -104,7 +117,7 @@ def _get_file_content_implementation(self, path: str, file_type: SerializableFil This is the actual implementation that gets wrapped by lru_cache. Args: - path: The normalized path to the file (without extension) + path: The API path to the file (e.g. `path/to/file`) file_type: The type of file to get the content of (SerializableFileType) Returns: @@ -155,31 +168,17 @@ def clear_cache(self) -> None: """Clear the LRU cache.""" self.get_file_content.cache_clear() # type: ignore [attr-defined] - def _normalize_path(self, path: str) -> str: - """Normalize the path by: - 1. Converting to a Path object to handle platform-specific separators - 2. Removing any file extensions - 3. Converting to a string with forward slashes and no leading/trailing slashes - """ - # Convert to Path object to handle platform-specific separators - path_obj = Path(path) - - # Reject absolute paths to ensure all paths are relative to base_dir. - # This maintains consistency with the remote filesystem where paths are relative to project root. - if path_obj.is_absolute(): - raise HumanloopRuntimeError( - f"Absolute paths are not supported: `{path}`. " - f"Paths should be relative to the base directory (`{self.base_dir}`)." - ) + def is_file(self, path: str) -> bool: + """Check if the path is a file by checking for .{file_type} extension for serializable file types. - # Remove extension, convert to string with forward slashes, and remove leading/trailing slashes - normalized = str(path_obj.with_suffix("")) - # Replace all backslashes and normalize multiple forward slashes - return "/".join(part for part in normalized.replace("\\", "/").split("/") if part) + Files are identified by having a supported extension (.prompt or .agent). + This method performs case-insensitive comparison and handles whitespace. - def is_file(self, path: str) -> bool: - """Check if the path is a file by checking for .{file_type} extension for serializable file types.""" - return path.endswith(tuple(f".{file_type}" for file_type in self.SERIALIZABLE_FILE_TYPES)) + Returns: + bool: True if the path ends with a supported file extension + """ + clean_path = path.strip().lower() # Convert to lowercase for case-insensitive comparison + return any(clean_path.endswith(f".{file_type}") for file_type in self.SERIALIZABLE_FILE_TYPES) def _save_serialized_file( self, @@ -208,7 +207,7 @@ def _pull_file(self, path: str, environment: Optional[str] = None) -> bool: """Pull a specific file from Humanloop to local filesystem. Returns: - True if the file was successfully pulled, False otherwise + True if the file was successfully pulled, False otherwise (e.g. if the file was not found) """ try: file = self.client.files.retrieve_by_path( @@ -244,8 +243,8 @@ def _pull_directory( Returns: Tuple of two lists: - - First list contains paths of successfully synced files - - Second list contains paths of files that failed to sync. + - First list contains paths of successfully pulled files + - Second list contains paths of files that failed to pull. Failures can occur due to missing content in the response or errors during local file writing. Raises: @@ -320,57 +319,75 @@ def _pull_directory( def pull(self, path: Optional[str] = None, environment: Optional[str] = None) -> Tuple[List[str], List[str]]: """Pull files from Humanloop to local filesystem. - If the path ends with .prompt or .agent, pulls that specific file. + If the path ends with `.prompt` or `.agent`, pulls that specific file. Otherwise, pulls all files under the specified path. If no path is provided, pulls all files from the root. Args: - path: The path to pull from (either a specific file or directory) + path: The path to pull from. Can be: + - A specific file with extension (e.g. "path/to/file.prompt") + - A directory without extension (e.g. "path/to/directory") + - None to pull all files from root + + Paths should not contain leading or trailing slashes environment: The environment to pull from Returns: Tuple of two lists: - - First list contains paths of successfully synced files - - Second list contains paths of files that failed to sync (e.g. failed to write to disk or missing raw content) + - First list contains paths of successfully pulled files + - Second list contains paths of files that failed to pull (e.g. failed to write to disk or missing raw content) Raises: HumanloopRuntimeError: If there's an error communicating with the API """ start_time = time.time() - normalized_path = self._normalize_path(path) if path else None - logger.info( - f"Starting pull operation: path={normalized_path or '(root)'}, environment={environment or '(default)'}" - ) + if path is None: + api_path = None + is_file_path = False + else: + path = path.strip() + # Check if path has leading/trailing slashes + if path != path.strip("/"): + raise HumanloopRuntimeError( + f"Invalid path: {path}. Path should not contain leading/trailing slashes. " + f'Valid examples: "path/to/file.prompt" or "path/to/directory"' + ) + + # Check if it's a file path (has extension) + is_file_path = self.is_file(path) + + # For API communication, we need path without extension + api_path = path_utils.normalize_path(path, strip_extension=True) + + logger.info(f"Starting pull: path={api_path or '(root)'}, environment={environment or '(default)'}") try: - if ( - normalized_path is None or path is None - ): # path being None means normalized_path is None, but we check both for improved type safety - # Pull all files from the root + if api_path is None: + # Pull all from root logger.debug("Pulling all files from root") successful_files, failed_files = self._pull_directory( path=None, environment=environment, ) else: - if self.is_file(path.strip()): - logger.debug(f"Pulling file: {normalized_path}") - if self._pull_file(path=normalized_path, environment=environment): - successful_files = [path] + if is_file_path: + logger.debug(f"Pulling file: {api_path}") + if self._pull_file(api_path, environment): + successful_files = [api_path] failed_files = [] else: successful_files = [] - failed_files = [path] + failed_files = [api_path] else: - logger.debug(f"Pulling directory: {normalized_path}") - successful_files, failed_files = self._pull_directory(normalized_path, environment) + logger.debug(f"Pulling directory: {api_path}") + successful_files, failed_files = self._pull_directory(api_path, environment) # Clear the cache at the end of each pull operation self.clear_cache() duration_ms = int((time.time() - start_time) * 1000) - logger.info(f"Pull completed in {duration_ms}ms: {len(successful_files)} files succeeded") + logger.info(f"Pull completed in {duration_ms}ms: {len(successful_files)} files pulled") return successful_files, failed_files except Exception as e: diff --git a/tests/custom/conftest.py b/tests/custom/conftest.py index 8e400483..7600afc0 100644 --- a/tests/custom/conftest.py +++ b/tests/custom/conftest.py @@ -86,10 +86,11 @@ def get_humanloop_client() -> GetHumanloopClientFn: if not os.getenv("HUMANLOOP_API_KEY"): pytest.fail("HUMANLOOP_API_KEY is not set for integration tests") - def _get_humanloop_client(use_local_files: bool = False) -> Humanloop: + def _get_humanloop_client(use_local_files: bool = False, local_files_directory: str = "humanloop") -> Humanloop: return Humanloop( api_key=os.getenv("HUMANLOOP_API_KEY"), use_local_files=use_local_files, + local_files_directory=local_files_directory, ) return _get_humanloop_client diff --git a/tests/custom/sync/__init__.py b/tests/custom/file_syncer/__init__.py similarity index 100% rename from tests/custom/sync/__init__.py rename to tests/custom/file_syncer/__init__.py diff --git a/tests/custom/file_syncer/test_file_syncer.py b/tests/custom/file_syncer/test_file_syncer.py new file mode 100644 index 00000000..7c500dd8 --- /dev/null +++ b/tests/custom/file_syncer/test_file_syncer.py @@ -0,0 +1,135 @@ +import logging +from pathlib import Path +from typing import Literal +from unittest.mock import Mock, patch + +import pytest + +from humanloop.error import HumanloopRuntimeError +from humanloop.sync.file_syncer import FileSyncer, SerializableFileType + + +@pytest.fixture +def mock_client() -> Mock: + return Mock() + + +@pytest.fixture +def file_syncer(mock_client: Mock, tmp_path: Path) -> FileSyncer: + return FileSyncer( + client=mock_client, + base_dir=str(tmp_path), + cache_size=10, + log_level=logging.DEBUG, # DEBUG level for testing # noqa: F821 + ) + + +def test_init(file_syncer: FileSyncer, tmp_path: Path): + """Test basic initialization of FileSyncer.""" + # GIVEN a FileSyncer instance + # THEN it should be initialized with correct base directory, cache size and file types + assert file_syncer.base_dir == tmp_path # Compare Path objects directly + assert file_syncer._cache_size == 10 + assert file_syncer.SERIALIZABLE_FILE_TYPES == frozenset(["prompt", "agent"]) + + +def test_is_file(file_syncer: FileSyncer): + """Test file type detection with case insensitivity.""" + # GIVEN a FileSyncer instance + + # WHEN checking various file paths with different extensions and cases + # THEN .prompt and .agent files (of any case) should return True + + # Standard lowercase extensions + assert file_syncer.is_file("test.prompt") + assert file_syncer.is_file("test.agent") + + # Uppercase extensions (case insensitivity) + assert file_syncer.is_file("test.PROMPT") + assert file_syncer.is_file("test.AGENT") + assert file_syncer.is_file("test.Prompt") + assert file_syncer.is_file("test.Agent") + + # With whitespace + assert file_syncer.is_file(" test.prompt ") + assert file_syncer.is_file(" test.agent ") + + # WHEN checking paths with invalid or no extensions + # THEN they should return False + + # Invalid file types + assert not file_syncer.is_file("test.txt") + assert not file_syncer.is_file("test.json") + assert not file_syncer.is_file("test.py") + + # No extension + assert not file_syncer.is_file("test") + assert not file_syncer.is_file("prompt") + assert not file_syncer.is_file("agent") + + # Partial extensions + assert not file_syncer.is_file("test.prom") + assert not file_syncer.is_file("test.age") + + +def test_save_and_read_file(file_syncer: FileSyncer): + """Test saving and reading files.""" + # GIVEN a file content and path + content = "test content" + path = "test/path" + file_type: SerializableFileType = "prompt" + + # WHEN saving the file + file_syncer._save_serialized_file(content, path, "prompt") + saved_path = file_syncer.base_dir / path + saved_path = saved_path.parent / f"{saved_path.stem}.{file_type}" + + # THEN the file should exist on disk + assert saved_path.exists() + + # WHEN reading the file + read_content = file_syncer.get_file_content(path, file_type) + + # THEN the content should match + assert read_content == content + + +def test_error_handling(file_syncer: FileSyncer): + """Test error handling in various scenarios.""" + # GIVEN a nonexistent file + # WHEN trying to read it + # THEN a HumanloopRuntimeError should be raised + with pytest.raises(HumanloopRuntimeError, match="Local file not found"): + file_syncer.get_file_content("nonexistent", "prompt") + + # GIVEN an API error + # WHEN trying to pull a file + # THEN it should return False + with patch.object(file_syncer.client.files, "retrieve_by_path", side_effect=Exception("API Error")): + assert not file_syncer._pull_file("test.prompt") + + +def test_cache_functionality(file_syncer: FileSyncer): + """Test LRU cache functionality.""" + # GIVEN a test file + content = "test content" + path = "test/path" + file_type: Literal["prompt", "agent"] = "prompt" + file_syncer._save_serialized_file(content, path, file_type) + + # WHEN reading the file for the first time + file_syncer.get_file_content(path, file_type) + # THEN it should hit disk (implicitly verified by no cache hit) + + # WHEN modifying the file on disk + saved_path = file_syncer.base_dir / f"{path}.{file_type}" + saved_path.write_text("modified content") + + # THEN subsequent reads should use cache + assert file_syncer.get_file_content(path, file_type) == content + + # WHEN clearing the cache + file_syncer.clear_cache() + + # THEN new content should be read from disk + assert file_syncer.get_file_content(path, file_type) == "modified content" diff --git a/tests/custom/integration/test_sync_cli.py b/tests/custom/integration/test_cli.py similarity index 81% rename from tests/custom/integration/test_sync_cli.py rename to tests/custom/integration/test_cli.py index 5631d5f0..17f34db4 100644 --- a/tests/custom/integration/test_sync_cli.py +++ b/tests/custom/integration/test_cli.py @@ -20,13 +20,13 @@ def no_env_file_loading(): yield -def test_pull_without_api_key(cli_runner: CliRunner, no_humanloop_api_key_in_env, no_env_file_loading): - """GIVEN no API key in environment - WHEN running pull command - THEN it should fail with appropriate error message - """ +def test_pull_without_api_key(cli_runner: CliRunner, no_humanloop_api_key_in_env, no_env_file_loading, tmp_path: Path): + """Test error handling when no API key is available.""" + # GIVEN a base directory + base_dir = str(tmp_path) + # WHEN running pull command - result = cli_runner.invoke(cli, ["pull", "--local-files-directory", "humanloop"]) + result = cli_runner.invoke(cli, ["pull", "--local-files-directory", base_dir]) # THEN it should fail with appropriate error message assert result.exit_code == 1 # Our custom error code for API key issues @@ -37,10 +37,11 @@ def test_pull_without_api_key(cli_runner: CliRunner, no_humanloop_api_key_in_env def test_pull_basic( cli_runner: CliRunner, syncable_files_fixture: list[SyncableFile], - tmp_path: Path, # this path is used as a temporary store for files locally + tmp_path: Path, ): + """Test basic file pulling functionality.""" # GIVEN a base directory for pulled files - base_dir = str(tmp_path / "humanloop") + base_dir = str(tmp_path) # WHEN running pull command result = cli_runner.invoke(cli, ["pull", "--local-files-directory", base_dir, "--verbose"]) @@ -64,19 +65,11 @@ def test_pull_with_specific_path( syncable_files_fixture: list[SyncableFile], tmp_path: Path, ): - """GIVEN a specific path to pull - WHEN running pull command with path - THEN it should pull only files from that path - """ + """Test pulling files from a specific path.""" # GIVEN a base directory and specific path - base_dir = str(tmp_path / "humanloop") - test_path = syncable_files_fixture[ - 0 - ].path.split( - "/" - )[ - 0 - ] # Retrieve the prefix of the first file's path which corresponds to the sdk_test_dir used within syncable_files_fixture + base_dir = str(tmp_path) + # Retrieve the prefix of the first file's path which corresponds to the sdk_test_dir used within syncable_files_fixture + test_path = syncable_files_fixture[0].path.split("/")[0] # WHEN running pull command with path result = cli_runner.invoke(cli, ["pull", "--local-files-directory", base_dir, "--path", test_path, "--verbose"]) @@ -100,8 +93,9 @@ def test_pull_with_environment( syncable_files_fixture: list[SyncableFile], tmp_path: Path, ): + """Test pulling files from a specific environment.""" # GIVEN a base directory and environment - base_dir = str(tmp_path / "humanloop") + base_dir = str(tmp_path) environment = "staging" # WHEN running pull command with environment @@ -127,8 +121,9 @@ def test_pull_with_quiet_mode( syncable_files_fixture: list[SyncableFile], tmp_path: Path, ): + """Test pulling files with quiet mode enabled.""" # GIVEN a base directory and quiet mode - base_dir = str(tmp_path / "humanloop") + base_dir = str(tmp_path) # WHEN running pull command with quiet mode result = cli_runner.invoke(cli, ["pull", "--local-files-directory", base_dir, "--quiet"]) @@ -146,12 +141,15 @@ def test_pull_with_quiet_mode( def test_pull_with_invalid_path( cli_runner: CliRunner, + tmp_path: Path, ): - # GIVEN an invalid base directory + """Test error handling when pulling from an invalid path.""" + # GIVEN an invalid path path = "nonexistent/path" + base_dir = str(tmp_path) # WHEN running pull command - result = cli_runner.invoke(cli, ["pull", "--path", path]) + result = cli_runner.invoke(cli, ["pull", "--path", path, "--local-files-directory", base_dir]) # THEN it should fail assert result.exit_code == 1 @@ -159,9 +157,10 @@ def test_pull_with_invalid_path( def test_pull_with_invalid_environment(cli_runner: CliRunner, tmp_path: Path): + """Test error handling when pulling from an invalid environment.""" # GIVEN an invalid environment environment = "nonexistent" - base_dir = str(tmp_path / "humanloop") + base_dir = str(tmp_path) # WHEN running pull command result = cli_runner.invoke( diff --git a/tests/custom/integration/test_file_sync.py b/tests/custom/integration/test_file_sync.py new file mode 100644 index 00000000..dcda822b --- /dev/null +++ b/tests/custom/integration/test_file_sync.py @@ -0,0 +1,102 @@ +from pathlib import Path + +import pytest + +from humanloop.error import HumanloopRuntimeError +from tests.custom.types import GetHumanloopClientFn, SyncableFile + + +def test_pull_basic( + syncable_files_fixture: list[SyncableFile], + get_humanloop_client: GetHumanloopClientFn, + tmp_path: Path, +): + """Test basic file pulling from remote to local filesystem.""" + # GIVEN a set of files in the remote system (from syncable_files_fixture) + humanloop_client = get_humanloop_client(local_files_directory=str(tmp_path)) + + # WHEN running the pull operation + humanloop_client.pull() + + # THEN our local filesystem should mirror the remote filesystem in the HL Workspace + for file in syncable_files_fixture: + extension = f".{file.type}" + local_path = tmp_path / f"{file.path}{extension}" + + # THEN the file and its directory should exist + assert local_path.exists(), f"Expected synced file at {local_path}" + assert local_path.parent.exists(), f"Expected directory at {local_path.parent}" + + # THEN the file should not be empty + content = local_path.read_text() + assert content, f"File at {local_path} should not be empty" + + +def test_pull_with_invalid_path( + get_humanloop_client: GetHumanloopClientFn, + sdk_test_dir: str, + tmp_path: Path, +): + """Test error handling when path doesn't exist.""" + humanloop_client = get_humanloop_client(local_files_directory=str(tmp_path)) + non_existent_path = f"{sdk_test_dir}/non_existent_directory" + + # Note: This test currently relies on the specific error message from list_files(). + # If implementing explicit directory validation in the future, this test may need updating. + with pytest.raises(HumanloopRuntimeError, match=f"Directory `{non_existent_path}` does not exist"): + humanloop_client.pull(path=non_existent_path) + + +def test_pull_with_invalid_environment( + get_humanloop_client: GetHumanloopClientFn, + tmp_path: Path, +): + """Test error handling when environment doesn't exist.""" + humanloop_client = get_humanloop_client(local_files_directory=str(tmp_path)) + with pytest.raises(HumanloopRuntimeError, match="Environment .* does not exist"): + humanloop_client.pull(environment="invalid_environment") + + +# def test_pull_with_environment( +# get_humanloop_client: GetHumanloopClientFn, +# syncable_files_fixture: list[SyncableFile], +# ): +# """Test pulling files filtered by a specific environment.""" +# # NOTE: This test is currently not feasible to implement because: +# # 1. We have no way of deploying to an environment using its name, only by ID +# # 2. There's no API endpoint to retrieve environments for an organization +# # +# # If implemented, this test would: +# # 1. Deploy one of the syncable files to a specific environment (e.g., "production" as it's non-default) +# # 2. Pull files filtering by the production environment +# # 3. Check if the deployed file is present in the local filesystem +# # 4. Verify that none of the other syncable files (that weren't deployed to production) are present +# # This would confirm that environment filtering works correctly + + +def test_pull_with_path_filter( + get_humanloop_client: GetHumanloopClientFn, + syncable_files_fixture: list[SyncableFile], + sdk_test_dir: str, + tmp_path: Path, +): + """Test that filtering by path correctly limits which files are pulled.""" + # GIVEN a client + humanloop_client = get_humanloop_client(local_files_directory=str(tmp_path)) + + # WHEN pulling only files from the sdk_test_dir path + humanloop_client.pull(path=sdk_test_dir) + + # THEN count the total number of files pulled + pulled_file_count = 0 + for path in tmp_path.glob("**/*"): + if path.is_file(): + # Check that the file is not empty + content = path.read_text() + assert content, f"File at {path} should not be empty" + pulled_file_count += 1 + + # The count should match our fixture length + assert pulled_file_count == len(syncable_files_fixture), ( + f"Expected {len(syncable_files_fixture)} files, got {pulled_file_count}" + ) diff --git a/tests/custom/integration/test_local_file_operations.py b/tests/custom/integration/test_local_file_operations.py new file mode 100644 index 00000000..ca1dd35a --- /dev/null +++ b/tests/custom/integration/test_local_file_operations.py @@ -0,0 +1,333 @@ +from pathlib import Path +from typing import Callable + +import pytest + +from humanloop.error import HumanloopRuntimeError +from humanloop.requests.chat_message import ChatMessageParams +from tests.custom.types import GetHumanloopClientFn, SyncableFile + + +@pytest.mark.parametrize( + "path_generator,expected_error,test_case_description", + [ + # Extension path test cases + # Using lambdas to defer path generation until we have access to the test_file fixture + ( + lambda test_file: f"{test_file.path}.{test_file.type}", + "includes a file extension which is not supported", + "Standard extension", + ), + ( + lambda test_file: f"{test_file.path}.{test_file.type.upper()}", + "includes a file extension which is not supported", + "Uppercase extension", + ), + ( + lambda test_file: f"{test_file.path}.{test_file.type.capitalize()}", + "includes a file extension which is not supported", + "Mixed case extension", + ), + ( + lambda test_file: f" {test_file.path}.{test_file.type} ", + "includes a file extension which is not supported", + "With whitespace", + ), + # Slash path test cases + (lambda test_file: f"{test_file.path}/", "Path .* format is invalid", "Trailing slash"), + (lambda test_file: f"/{test_file.path}", "Path .* format is invalid", "Leading slash"), + (lambda test_file: f"/{test_file.path}/", "Path .* format is invalid", "Both leading and trailing slashes"), + ( + lambda test_file: f"//{test_file.path}//", + "Path .* format is invalid", + "Multiple leading and trailing slashes", + ), + # Combined path test cases + ( + lambda test_file: f"{test_file.path}.{test_file.type}/", + "Path .* format is invalid", + "Extension and trailing slash", + ), + ( + lambda test_file: f"/{test_file.path}.{test_file.type}", + "Path .* format is invalid", + "Extension and leading slash", + ), + ], + ids=lambda x: x[2] if isinstance(x, tuple) else x, # Use test_case_description as the test ID in pytest output +) +def test_path_validation( + get_humanloop_client: GetHumanloopClientFn, + syncable_files_fixture: list[SyncableFile], + tmp_path: Path, + path_generator: Callable[[SyncableFile], str], + expected_error: str, + test_case_description: str, +): + """Test validation of path formats for local file operations.""" + # GIVEN a client with local files enabled and remote files pulled + humanloop_client = get_humanloop_client(use_local_files=True, local_files_directory=str(tmp_path)) + humanloop_client.pull() + test_file = syncable_files_fixture[0] + + # WHEN using the test path + test_path = path_generator(test_file) + + # THEN appropriate error should be raised + with pytest.raises(HumanloopRuntimeError, match=expected_error): + if test_file.type == "prompt": + humanloop_client.prompts.call(path=test_path, messages=[{"role": "user", "content": "Testing"}]) + elif test_file.type == "agent": + humanloop_client.agents.call(path=test_path, messages=[{"role": "user", "content": "Testing"}]) + + +def test_local_file_call( + get_humanloop_client: GetHumanloopClientFn, + sdk_test_dir: str, + tmp_path: Path, +): + """Test calling the API with a local prompt file.""" + # GIVEN a local prompt file with proper system tag + prompt_content = """--- +model: gpt-4o +temperature: 1.0 +max_tokens: -1 +top_p: 1.0 +presence_penalty: 0.0 +frequency_penalty: 0.0 +provider: openai +endpoint: chat +tools: [] +--- + + +You are a helpful assistant that provides concise answers. When asked about capitals of countries, +you respond with just the capital name, lowercase, with no punctuation or additional text. + +""" + + # Create local file structure in temporary directory + test_path = f"{sdk_test_dir}/capital_prompt" + file_path = tmp_path / f"{test_path}.prompt" + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(prompt_content) + + # GIVEN a client with local files enabled + client = get_humanloop_client(use_local_files=True, local_files_directory=str(tmp_path)) + + # WHEN calling the API with the local file path (without extension) + call_messages = [ChatMessageParams(role="user", content="What is the capital of France?")] + response = client.prompts.call(path=test_path, messages=call_messages) + + # THEN the response should be successful + assert response is not None + assert response.logs is not None + assert len(response.logs) > 0 + + # AND the response should contain the expected output format (lowercase city name) + assert response.logs[0].output is not None and "paris" in response.logs[0].output.lower() + + # AND the prompt used should match our expected path + assert response.prompt is not None + assert response.prompt.path == test_path + + +def test_local_file_log( + get_humanloop_client: GetHumanloopClientFn, + sdk_test_dir: str, + tmp_path: Path, +): + """Test logging data with a local prompt file.""" + # GIVEN a local prompt file with proper system tag + prompt_content = """--- +model: gpt-4o +temperature: 1.0 +max_tokens: -1 +top_p: 1.0 +presence_penalty: 0.0 +frequency_penalty: 0.0 +provider: openai +endpoint: chat +tools: [] +--- + + +You are a helpful assistant that answers questions about geography. + +""" + + # Create local file structure in temporary directory + test_path = f"{sdk_test_dir}/geography_prompt" + file_path = tmp_path / f"{test_path}.prompt" + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(prompt_content) + + # GIVEN a client with local files enabled + client = get_humanloop_client(use_local_files=True, local_files_directory=str(tmp_path)) + + # GIVEN message content to log + test_output = "Paris is the capital of France." + + # WHEN logging the data with the local file path + messages = [ChatMessageParams(role="user", content="What is the capital of France?")] + response = client.prompts.log(path=test_path, messages=messages, output=test_output) + + # THEN the log should be successful + assert response is not None + assert response.prompt_id is not None + assert response.id is not None # log ID + + # WHEN retrieving the logged prompt details + prompt_details = client.prompts.get(id=response.prompt_id) + + # THEN the details should match our expected path + assert prompt_details is not None + assert test_path in prompt_details.path + + +def test_overload_version_environment_handling( + get_humanloop_client: GetHumanloopClientFn, + syncable_files_fixture: list[SyncableFile], + tmp_path: Path, +): + """Test handling of version_id and environment parameters with local files.""" + # GIVEN a client with use_local_files=True and pulled files + humanloop_client = get_humanloop_client(use_local_files=True, local_files_directory=str(tmp_path)) + humanloop_client.pull() + + test_message = [ChatMessageParams(role="user", content="Testing")] + + # GIVEN a test file that exists locally + test_file = syncable_files_fixture[0] + extension = f".{test_file.type}" + local_path = tmp_path / f"{test_file.path}{extension}" + + # THEN the file should exist locally + assert local_path.exists(), f"Expected pulled file at {local_path}" + assert local_path.parent.exists(), f"Expected directory at {local_path.parent}" + + # WHEN calling with version_id + # THEN a HumanloopRuntimeError should be raised + with pytest.raises(HumanloopRuntimeError, match="Cannot use local file.*version_id or environment was specified"): + if test_file.type == "prompt": + humanloop_client.prompts.call( + path=test_file.path, + version_id=test_file.version_id, + messages=test_message, + ) + elif test_file.type == "agent": + humanloop_client.agents.call( + path=test_file.path, + version_id=test_file.version_id, + messages=test_message, + ) + + # WHEN calling with environment + # THEN a HumanloopRuntimeError should be raised + with pytest.raises(HumanloopRuntimeError, match="Cannot use local file.*version_id or environment was specified"): + if test_file.type == "prompt": + humanloop_client.prompts.call( + path=test_file.path, + environment="production", + messages=test_message, + ) + elif test_file.type == "agent": + humanloop_client.agents.call( + path=test_file.path, + environment="production", + messages=test_message, + ) + + # WHEN calling with both version_id and environment + # THEN a HumanloopRuntimeError should be raised + with pytest.raises(HumanloopRuntimeError, match="Cannot use local file.*version_id or environment was specified"): + if test_file.type == "prompt": + humanloop_client.prompts.call( + path=test_file.path, + version_id=test_file.version_id, + environment="staging", + messages=test_message, + ) + elif test_file.type == "agent": + humanloop_client.agents.call( + path=test_file.path, + version_id=test_file.version_id, + environment="staging", + messages=test_message, + ) + + +# def test_agent_local_file_usage( +# get_humanloop_client: GetHumanloopClientFn, +# sdk_test_dir: str, +# tmp_path: Path, +# ): +# """Test using a local agent file for API calls.""" +# # NOTE: This test has been disabled as it fails intermittently in automated test runs +# # but works correctly when tested manually. The issue appears to be related to test +# # environment differences rather than actual code functionality. +# # TODO: Investigate and fix the underlying issue with test stability. +# +# # GIVEN a local agent file with proper system tag +# agent_content = """--- +# model: gpt-4o +# temperature: 1.0 +# max_tokens: -1 +# top_p: 1.0 +# presence_penalty: 0.0 +# frequency_penalty: 0.0 +# max_iterations: 5 +# provider: openai +# endpoint: chat +# tools: [] +# --- +# +# +# You are a helpful agent that provides concise answers. When asked about capitals of countries, +# you respond with just the capital name, lowercase, with no punctuation or additional text. +# +# """ +# +# # Create local file structure in temporary directory +# test_path = f"{sdk_test_dir}/capital_agent" +# file_path = tmp_path / f"{test_path}.agent" +# file_path.parent.mkdir(parents=True, exist_ok=True) +# file_path.write_text(agent_content) +# +# # GIVEN a client with local files enabled +# client = get_humanloop_client(use_local_files=True, local_files_directory=str(tmp_path)) +# +# # WHEN calling the API with the local file path (without extension) +# agent_call_messages = [ChatMessageParams(role="user", content="What is the capital of France?")] +# response = client.agents.call( +# path=test_path, messages=agent_call_messages +# ) +# +# # THEN the response should be successful +# assert response is not None +# assert response.logs is not None +# assert len(response.logs) > 0 +# +# # AND the response should contain the expected output format (lowercase city name) +# assert response.logs[0].output is not None and "paris" in response.logs[0].output.lower() +# +# # AND the agent used should match our expected path +# assert response.agent is not None +# assert response.agent.path == test_path +# +# # WHEN logging with the local agent file +# test_output = "Berlin is the capital of Germany." +# agent_messages = [ChatMessageParams(role="user", content="What is the capital of Germany?")] +# log_response = client.agents.log(path=test_path, messages=agent_messages, output=test_output) +# +# # THEN the log should be successful +# assert log_response is not None +# assert log_response.agent_id is not None +# assert log_response.id is not None # log ID +# +# # WHEN retrieving the logged agent details +# agent_details = client.agents.get(id=log_response.agent_id) +# +# # THEN the details should match our expected path +# assert agent_details is not None +# assert test_path in agent_details.path diff --git a/tests/custom/integration/test_sync.py b/tests/custom/integration/test_sync.py deleted file mode 100644 index 8b33f7a4..00000000 --- a/tests/custom/integration/test_sync.py +++ /dev/null @@ -1,211 +0,0 @@ -import typing -from pathlib import Path -from typing import List, Union - -import pytest - -from humanloop import AgentResponse, PromptResponse -from humanloop.agents.client import AgentsClient -from humanloop.error import HumanloopRuntimeError -from humanloop.prompts.client import PromptsClient -from tests.custom.types import GetHumanloopClientFn, SyncableFile - - -@pytest.fixture -def cleanup_local_files(): - """Cleanup any locally synced files after tests""" - yield - local_dir = Path("humanloop") - if local_dir.exists(): - import shutil - - shutil.rmtree(local_dir) - - -def test_pull_basic( - syncable_files_fixture: List[SyncableFile], - get_humanloop_client: GetHumanloopClientFn, -): - """Test that humanloop.sync() correctly syncs remote files to local filesystem""" - # GIVEN a set of files in the remote system (from syncable_files_fixture) - humanloop_client = get_humanloop_client() - - # WHEN running the sync - humanloop_client.pull() - - # THEN our local filesystem should mirror the remote filesystem in the HL Workspace - for file in syncable_files_fixture: - extension = f".{file.type}" - local_path = Path("humanloop") / f"{file.path}{extension}" - - # THEN the file and its directory should exist - assert local_path.exists(), f"Expected synced file at {local_path}" - assert local_path.parent.exists(), f"Expected directory at {local_path.parent}" - - # THEN the file should not be empty - content = local_path.read_text() - assert content, f"File at {local_path} should not be empty" - - -def test_overload_with_local_files( - get_humanloop_client: GetHumanloopClientFn, - syncable_files_fixture: List[SyncableFile], -): - """Test that overload_with_local_files correctly handles local files.""" - # GIVEN a client with use_local_files=True and pulled files - humanloop_client = get_humanloop_client(use_local_files=True) - humanloop_client.pull() - - # GIVEN a test file from the structure - test_file = syncable_files_fixture[0] - extension = f".{test_file.type}" - local_path = Path("humanloop") / f"{test_file.path}{extension}" - - # THEN the file should exist locally - assert local_path.exists(), f"Expected pulled file at {local_path}" - assert local_path.parent.exists(), f"Expected directory at {local_path.parent}" - - # WHEN calling the file - response: Union[AgentResponse, PromptResponse] - if test_file.type == "prompt": - response = humanloop_client.prompts.call( # type: ignore [assignment] - path=test_file.path, messages=[{"role": "user", "content": "Testing"}] - ) - elif test_file.type == "agent": - response = humanloop_client.agents.call( # type: ignore [assignment] - path=test_file.path, messages=[{"role": "user", "content": "Testing"}] - ) - # THEN the response should not be None - assert response is not None - - # WHEN calling with an invalid path - # THEN it should raise HumanloopRuntimeError - with pytest.raises(HumanloopRuntimeError): - try: - sub_client: Union[PromptsClient, AgentsClient] = typing.cast( - Union[PromptsClient, AgentsClient], - { - "prompt": humanloop_client.prompts, - "agent": humanloop_client.agents, - }[test_file.type], - ) - sub_client.call(path="invalid/path") - except KeyError: - raise NotImplementedError(f"Unknown file type: {test_file.type}") - - -def test_overload_log_with_local_files( - get_humanloop_client: GetHumanloopClientFn, - syncable_files_fixture: List[SyncableFile], - sdk_test_dir: str, -): - """Test that overload_with_local_files correctly handles local files for log operations.""" - # GIVEN a client with use_local_files=True and pulled files - humanloop_client = get_humanloop_client(use_local_files=True) - humanloop_client.pull() - - # GIVEN a test file from the structure - test_file = syncable_files_fixture[0] - extension = f".{test_file.type}" - local_path = Path("humanloop") / f"{test_file.path}{extension}" - - # THEN the file should exist locally - assert local_path.exists(), f"Expected pulled file at {local_path}" - assert local_path.parent.exists(), f"Expected directory at {local_path.parent}" - - # WHEN logging with the pulled file - if test_file.type == "prompt": - response = humanloop_client.prompts.log( # type: ignore [assignment] - path=test_file.path, messages=[{"role": "user", "content": "Testing"}], output="Test response" - ) - elif test_file.type == "agent": - response = humanloop_client.agents.log( # type: ignore [assignment] - path=test_file.path, messages=[{"role": "user", "content": "Testing"}], output="Test response" - ) - # THEN the response should not be None - assert response is not None - - # WHEN logging with an invalid path - # THEN it should raise HumanloopRuntimeError - with pytest.raises(HumanloopRuntimeError): - if test_file.type == "prompt": - humanloop_client.prompts.log( - path=f"{sdk_test_dir}/invalid/path", - messages=[{"role": "user", "content": "Testing"}], - output="Test response", - ) - elif test_file.type == "agent": - humanloop_client.agents.log( - path=f"{sdk_test_dir}/invalid/path", - messages=[{"role": "user", "content": "Testing"}], - output="Test response", - ) - - -def test_overload_version_environment_handling( - get_humanloop_client: GetHumanloopClientFn, - syncable_files_fixture: List[SyncableFile], -): - """Test that overload_with_local_files correctly handles version_id and environment parameters.""" - # GIVEN a client with use_local_files=True and pulled files - humanloop_client = get_humanloop_client(use_local_files=True) - humanloop_client.pull() - - # GIVEN a test file from the structure - test_file = syncable_files_fixture[0] - extension = f".{test_file.type}" - local_path = Path("humanloop") / f"{test_file.path}{extension}" - - # THEN the file should exist locally - assert local_path.exists(), f"Expected pulled file at {local_path}" - assert local_path.parent.exists(), f"Expected directory at {local_path.parent}" - - # WHEN calling with version_id - # THEN it should raise HumanloopRuntimeError - with pytest.raises(HumanloopRuntimeError, match="Cannot use local file.*version_id or environment was specified"): - if test_file.type == "prompt": - humanloop_client.prompts.call( - path=test_file.path, - version_id=test_file.version_id, - messages=[{"role": "user", "content": "Testing"}], - ) - elif test_file.type == "agent": - humanloop_client.agents.call( - path=test_file.path, - version_id=test_file.version_id, - messages=[{"role": "user", "content": "Testing"}], - ) - - # WHEN calling with environment - # THEN it should raise HumanloopRuntimeError - with pytest.raises(HumanloopRuntimeError, match="Cannot use local file.*version_id or environment was specified"): - if test_file.type == "prompt": - humanloop_client.prompts.call( - path=test_file.path, - environment="production", - messages=[{"role": "user", "content": "Testing"}], - ) - elif test_file.type == "agent": - humanloop_client.agents.call( - path=test_file.path, - environment="production", - messages=[{"role": "user", "content": "Testing"}], - ) - - # WHEN calling with both version_id and environment - # THEN it should raise HumanloopRuntimeError - with pytest.raises(HumanloopRuntimeError, match="Cannot use local file.*version_id or environment was specified"): - if test_file.type == "prompt": - humanloop_client.prompts.call( - path=test_file.path, - version_id=test_file.version_id, - environment="staging", - messages=[{"role": "user", "content": "Testing"}], - ) - elif test_file.type == "agent": - humanloop_client.agents.call( - path=test_file.path, - version_id=test_file.version_id, - environment="staging", - messages=[{"role": "user", "content": "Testing"}], - ) diff --git a/tests/custom/sync/test_client.py b/tests/custom/sync/test_client.py deleted file mode 100644 index ac83d259..00000000 --- a/tests/custom/sync/test_client.py +++ /dev/null @@ -1,128 +0,0 @@ -import logging -from pathlib import Path -from typing import Literal -from unittest.mock import Mock, patch - -import pytest - -from humanloop.error import HumanloopRuntimeError -from humanloop.sync.sync_client import SerializableFileType, SyncClient - - -@pytest.fixture -def mock_client() -> Mock: - return Mock() - - -@pytest.fixture -def sync_client(mock_client: Mock, tmp_path: Path) -> SyncClient: - return SyncClient( - client=mock_client, - base_dir=str(tmp_path), - cache_size=10, - log_level=logging.DEBUG, # DEBUG level for testing # noqa: F821 - ) - - -def test_init(sync_client: SyncClient, tmp_path: Path): - """Test basic initialization of SyncClient.""" - # GIVEN a SyncClient instance - # THEN it should be initialized with correct base directory, cache size and file types - assert sync_client.base_dir == tmp_path - assert sync_client._cache_size == 10 - assert sync_client.SERIALIZABLE_FILE_TYPES == frozenset(["prompt", "agent"]) - - -def test_normalize_path(sync_client: SyncClient): - """Test path normalization functionality.""" - # GIVEN various file paths with different formats - test_cases = [ - ("path/to/file.prompt", "path/to/file"), - ("path\\to\\file.agent", "path/to/file"), - ("trailing/slashes/file.agent/", "trailing/slashes/file"), - ("multiple//slashes//file.prompt", "multiple/slashes/file"), - ] - - for input_path, expected in test_cases: - # WHEN they are normalized - normalized = sync_client._normalize_path(input_path) - # THEN they should be converted to the expected format - assert normalized == expected - - # Test absolute path raises error - with pytest.raises(HumanloopRuntimeError, match="Absolute paths are not supported"): - sync_client._normalize_path("/leading/slashes/file.prompt") - - -def test_is_file(sync_client: SyncClient): - """Test file type detection.""" - # GIVEN various file paths - # WHEN checking if they are valid file types - # THEN only .prompt and .agent files should return True - assert sync_client.is_file("test.prompt") - assert sync_client.is_file("test.agent") - assert not sync_client.is_file("test.txt") - assert not sync_client.is_file("test") - - -def test_save_and_read_file(sync_client: SyncClient): - """Test saving and reading files.""" - # GIVEN a file content and path - content = "test content" - path = "test/path" - file_type: SerializableFileType = "prompt" - - # WHEN saving the file - sync_client._save_serialized_file(content, path, "prompt") - saved_path = sync_client.base_dir / path - saved_path = saved_path.parent / f"{saved_path.stem}.{file_type}" - - # THEN the file should exist on disk - assert saved_path.exists() - - # WHEN reading the file - read_content = sync_client.get_file_content(path, file_type) - - # THEN the content should match - assert read_content == content - - -def test_error_handling(sync_client: SyncClient): - """Test error handling in various scenarios.""" - # GIVEN a nonexistent file - # WHEN trying to read it - # THEN a HumanloopRuntimeError should be raised - with pytest.raises(HumanloopRuntimeError, match="Local file not found"): - sync_client.get_file_content("nonexistent", "prompt") - - # GIVEN an API error - # WHEN trying to pull a file - # THEN it should return False - with patch.object(sync_client.client.files, "retrieve_by_path", side_effect=Exception("API Error")): - assert not sync_client._pull_file("test.prompt") - - -def test_cache_functionality(sync_client: SyncClient): - """Test LRU cache functionality.""" - # GIVEN a test file - content = "test content" - path = "test/path" - file_type: Literal["prompt", "agent"] = "prompt" - sync_client._save_serialized_file(content, path, file_type) - - # WHEN reading the file for the first time - sync_client.get_file_content(path, file_type) - # THEN it should hit disk (implicitly verified by no cache hit) - - # WHEN modifying the file on disk - saved_path = sync_client.base_dir / f"{path}.{file_type}" - saved_path.write_text("modified content") - - # THEN subsequent reads should use cache - assert sync_client.get_file_content(path, file_type) == content - - # WHEN clearing the cache - sync_client.clear_cache() - - # THEN new content should be read from disk - assert sync_client.get_file_content(path, file_type) == "modified content" diff --git a/tests/custom/test_client.py b/tests/custom/test_client.py deleted file mode 100644 index 3e7c8334..00000000 --- a/tests/custom/test_client.py +++ /dev/null @@ -1,7 +0,0 @@ -import pytest - - -# Get started with writing tests with pytest at https://docs.pytest.org -@pytest.mark.skip(reason="Unimplemented") -def test_client() -> None: - assert True is True diff --git a/tests/custom/types.py b/tests/custom/types.py index b270d9fa..81344c53 100644 --- a/tests/custom/types.py +++ b/tests/custom/types.py @@ -5,7 +5,7 @@ class GetHumanloopClientFn(Protocol): - def __call__(self, use_local_files: bool = False) -> Humanloop: ... + def __call__(self, use_local_files: bool = False, local_files_directory: str = "humanloop") -> Humanloop: ... class SyncableFile(NamedTuple): diff --git a/tests/custom/unit/test_path_utils.py b/tests/custom/unit/test_path_utils.py new file mode 100644 index 00000000..320a826b --- /dev/null +++ b/tests/custom/unit/test_path_utils.py @@ -0,0 +1,38 @@ +import pytest + +from humanloop import path_utils + + +@pytest.mark.parametrize( + "input_path, expected_with_extension, expected_without_extension", + [ + # Basic cases + ("path/to/file.prompt", "path/to/file.prompt", "path/to/file"), + ("path\\to\\file.agent", "path/to/file.agent", "path/to/file"), + ("/leading/slashes/file.prompt", "leading/slashes/file.prompt", "leading/slashes/file"), + ("trailing/slashes/file.agent/", "trailing/slashes/file.agent", "trailing/slashes/file"), + ("multiple//slashes//file.prompt", "multiple/slashes/file.prompt", "multiple/slashes/file"), + # Edge cases + ("path/to/file with spaces.prompt", "path/to/file with spaces.prompt", "path/to/file with spaces"), + ( + "path/to/file\\with\\backslashes.prompt", + "path/to/file/with/backslashes.prompt", + "path/to/file/with/backslashes", + ), + ("path/to/unicode/文件.prompt", "path/to/unicode/文件.prompt", "path/to/unicode/文件"), + ( + "path/to/special/chars/!@#$%^&*().prompt", + "path/to/special/chars/!@#$%^&*().prompt", + "path/to/special/chars/!@#$%^&*()", + ), + ], +) +def test_normalize_path(input_path, expected_with_extension, expected_without_extension): + """Test path normalization with various path formats.""" + # Test without stripping extension + normalized = path_utils.normalize_path(input_path, strip_extension=False) + assert normalized == expected_with_extension, f"Failed with strip_extension=False for '{input_path}'" + + # Test with extension stripping + normalized = path_utils.normalize_path(input_path, strip_extension=True) + assert normalized == expected_without_extension, f"Failed with strip_extension=True for '{input_path}'"